diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index fd1bfcda1e..c4efdf314b 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -29,6 +29,8 @@ use std::sync::Arc; use tokio::fs; use tokio::sync::RwLock; +const MAX_WORKSPACE_NAME_CHARS: usize = 80; + /// Workspace service. pub struct WorkspaceService { manager: Arc>, @@ -103,6 +105,28 @@ struct AssistantWorkspaceDescriptor { } impl WorkspaceService { + fn normalize_workspace_name(name: String) -> BitFunResult { + let name = name.trim(); + + if name.is_empty() { + return Err(BitFunError::service("Workspace name cannot be empty")); + } + + if name.chars().any(char::is_control) { + return Err(BitFunError::service( + "Workspace name cannot contain control characters", + )); + } + + if name.chars().count() > MAX_WORKSPACE_NAME_CHARS { + return Err(BitFunError::service(format!( + "Workspace name cannot exceed {MAX_WORKSPACE_NAME_CHARS} characters" + ))); + } + + Ok(name.to_string()) + } + fn collect_startup_restored_workspaces(manager: &WorkspaceManager) -> Vec { let mut targets = Vec::new(); let mut seen_workspace_ids = HashSet::new(); @@ -1093,6 +1117,11 @@ impl WorkspaceService { related_paths, } = updates; + let normalized_name = match name { + Some(name) => Some(Self::normalize_workspace_name(name)?), + None => None, + }; + let existing_workspace = { let manager = self.manager.read().await; manager @@ -1121,7 +1150,7 @@ impl WorkspaceService { BitFunError::service(format!("Workspace not found: {}", workspace_id)) })?; - if let Some(name) = name { + if let Some(name) = normalized_name { workspace.name = name; } @@ -2576,6 +2605,59 @@ mod tests { ); } + #[tokio::test] + async fn update_workspace_info_normalizes_and_validates_project_name() { + let env = TestEnvironment::new(); + let service = build_test_workspace_service(env.path_manager.clone()).await; + let workspace_root = env.create_workspace_dir("rename-project"); + let workspace = service + .open_workspace(workspace_root) + .await + .expect("workspace should open"); + + let renamed = service + .update_workspace_info( + &workspace.id, + WorkspaceInfoUpdates { + name: Some(" Renamed project ".to_string()), + description: None, + tags: None, + related_paths: None, + }, + ) + .await + .expect("valid project name should be accepted"); + assert_eq!(renamed.name, "Renamed project"); + assert_eq!( + service + .get_workspace(&workspace.id) + .await + .expect("renamed workspace should remain available") + .name, + "Renamed project" + ); + + for invalid_name in [ + " ".to_string(), + "Project\nName".to_string(), + "x".repeat(MAX_WORKSPACE_NAME_CHARS + 1), + ] { + let error = service + .update_workspace_info( + &workspace.id, + WorkspaceInfoUpdates { + name: Some(invalid_name), + description: None, + tags: None, + related_paths: None, + }, + ) + .await + .expect_err("invalid project name should be rejected"); + assert!(error.to_string().contains("Workspace name")); + } + } + #[test] fn normalize_related_path_description_treats_blank_as_none() { assert_eq!( diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 51bfc81c2e..d62aef68aa 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,9 +1,9 @@ import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; -import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, ListChecks, Loader2, Clock3, ShieldCheck } from 'lucide-react'; +import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, ListChecks, Loader2, Clock3, ShieldCheck, Pencil } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; -import { Button, ConfirmDialog, Modal, Tooltip } from '@/component-library'; +import { Button, ConfirmDialog, InputDialog, Modal, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; @@ -51,6 +51,9 @@ const WorkspaceProjectPermissionsDialog = lazy(() => import('./WorkspaceProjectP const WorkspaceSessionBatchModal = lazy(() => import('./WorkspaceSessionBatchModal')); const ScheduledJobsModal = lazy(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); +const MAX_WORKSPACE_NAME_CHARS = 80; +const WORKSPACE_NAME_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; + interface WorkspaceItemProps { workspace: WorkspaceInfo; isActive: boolean; @@ -85,6 +88,7 @@ const WorkspaceItem: React.FC = ({ closeWorkspaceById, deleteAssistantWorkspace, resetAssistantWorkspace, + renameWorkspace, } = useWorkspaceContext(); const { switchLeftPanelTab } = useApp(); const openNavScene = useNavSceneStore(s => s.openNavScene); @@ -120,6 +124,7 @@ const WorkspaceItem: React.FC = ({ const [searchIndexModalOpen, setSearchIndexModalOpen] = useState(false); const [scheduledJobsModalOpen, setScheduledJobsModalOpen] = useState(false); const [sessionBatchModalOpen, setSessionBatchModalOpen] = useState(false); + const [renameDialogOpen, setRenameDialogOpen] = useState(false); const [workspaceSearchEnabled, setWorkspaceSearchEnabled] = useState( () => aiExperienceConfigService.getSettings().enable_workspace_search, ); @@ -492,6 +497,44 @@ const WorkspaceItem: React.FC = ({ setProjectPermissionsDialogOpen(true); }, []); + const handleRequestRename = useCallback(() => { + setMenuOpen(false); + setRenameDialogOpen(true); + }, []); + + const validateWorkspaceName = useCallback((value: string): string | null => { + const normalizedName = value.trim(); + if (!normalizedName) { + return t('nav.workspaces.renameDialog.validation.required'); + } + if (WORKSPACE_NAME_CONTROL_CHARACTERS.test(normalizedName)) { + return t('nav.workspaces.renameDialog.validation.invalidCharacters'); + } + if (Array.from(normalizedName).length > MAX_WORKSPACE_NAME_CHARS) { + return t('nav.workspaces.renameDialog.validation.tooLong', { + max: MAX_WORKSPACE_NAME_CHARS, + }); + } + return null; + }, [t]); + + const handleRenameWorkspace = useCallback(async (name: string) => { + const normalizedName = name.trim(); + if (normalizedName === workspace.name) { + return; + } + + try { + await renameWorkspace(workspace.id, normalizedName); + notificationService.success(t('nav.workspaces.renamed'), { duration: 2500 }); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.renameFailed'), + { duration: 4000 } + ); + } + }, [renameWorkspace, t, workspace.id, workspace.name]); + const handleRequestDeleteAssistant = useCallback(() => { setMenuOpen(false); setDeleteDialogOpen(true); @@ -1333,6 +1376,17 @@ const WorkspaceItem: React.FC = ({ {t('nav.scheduledJobs.open')}
+ {isLinkedWorktree ? (
+ setRenameDialogOpen(false)} + onConfirm={(name) => { void handleRenameWorkspace(name); }} + title={t('nav.workspaces.renameDialog.title')} + description={t('nav.workspaces.renameDialog.description')} + placeholder={t('nav.workspaces.renameDialog.placeholder')} + defaultValue={workspace.name} + confirmText={t('actions.save')} + cancelText={t('actions.cancel')} + validator={validateWorkspaceName} + required={false} + /> setWorktreeModalOpen(false)} diff --git a/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts b/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts index 07c604b8d2..a4fb2534a8 100644 --- a/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts +++ b/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts @@ -38,6 +38,7 @@ export interface WorkspaceContextValue extends WorkspaceState { workspaceId: string, relatedPaths: WorkspaceInfo['relatedPaths'] ) => Promise; + renameWorkspace: (workspaceId: string, name: string) => Promise; scanWorkspaceInfo: () => Promise; refreshRecentWorkspaces: () => Promise; removeWorkspaceFromRecent: (workspaceId: string) => Promise; diff --git a/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx b/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx index a9d9639b59..aff146762f 100644 --- a/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx +++ b/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx @@ -59,6 +59,8 @@ export const WorkspaceProvider: React.FC = ({ children } workspaceId: string, relatedPaths: WorkspaceInfo['relatedPaths'] ) => workspaceManager.updateWorkspaceRelatedPaths(workspaceId, relatedPaths), + renameWorkspace: async (workspaceId: string, name: string) => + workspaceManager.renameWorkspace(workspaceId, name), scanWorkspaceInfo: async () => workspaceManager.scanWorkspaceInfo(), refreshRecentWorkspaces: async () => workspaceManager.refreshRecentWorkspaces(), removeWorkspaceFromRecent: async (workspaceId: string) => @@ -107,6 +109,8 @@ export const WorkspaceProvider: React.FC = ({ children } workspaceId: string, relatedPaths: WorkspaceInfo['relatedPaths'] ) => workspaceManager.updateWorkspaceRelatedPaths(workspaceId, relatedPaths), + renameWorkspace: async (workspaceId: string, name: string) => + workspaceManager.renameWorkspace(workspaceId, name), scanWorkspaceInfo: async () => workspaceManager.scanWorkspaceInfo(), refreshRecentWorkspaces: async () => workspaceManager.refreshRecentWorkspaces(), removeWorkspaceFromRecent: async (workspaceId: string) => @@ -268,6 +272,13 @@ export const WorkspaceProvider: React.FC = ({ children } return await workspaceManager.updateWorkspaceRelatedPaths(workspaceId, relatedPaths); }, []); + const renameWorkspace = useCallback(async ( + workspaceId: string, + name: string + ): Promise => { + return await workspaceManager.renameWorkspace(workspaceId, name); + }, []); + const refreshRecentWorkspaces = useCallback(async (): Promise => { return await workspaceManager.refreshRecentWorkspaces(); }, []); @@ -300,6 +311,7 @@ export const WorkspaceProvider: React.FC = ({ children } setActiveWorkspace, reorderOpenedWorkspacesInSection, updateWorkspaceRelatedPaths, + renameWorkspace, scanWorkspaceInfo, refreshRecentWorkspaces, removeWorkspaceFromRecent, @@ -319,6 +331,7 @@ export const WorkspaceProvider: React.FC = ({ children } setActiveWorkspace, reorderOpenedWorkspacesInSection, updateWorkspaceRelatedPaths, + renameWorkspace, scanWorkspaceInfo, refreshRecentWorkspaces, removeWorkspaceFromRecent, diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.test.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.test.ts index de8a77ddb6..1a47813f89 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.test.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.test.ts @@ -6,6 +6,7 @@ const globalStateMocks = vi.hoisted(() => ({ getRecentWorkspaces: vi.fn(), getOpenedWorkspaces: vi.fn(), getCurrentWorkspace: vi.fn(), + updateWorkspaceInfo: vi.fn(), })); const listenMock = vi.hoisted(() => vi.fn()); @@ -52,6 +53,7 @@ function configureGlobalState(): void { globalStateMocks.getRecentWorkspaces.mockResolvedValue([]); globalStateMocks.getOpenedWorkspaces.mockResolvedValue([]); globalStateMocks.getCurrentWorkspace.mockResolvedValue(null); + globalStateMocks.updateWorkspaceInfo.mockReset(); } async function getFreshWorkspaceManager() { @@ -314,3 +316,46 @@ describe('WorkspaceManager startup initialization', () => { }); }); }); + +describe('WorkspaceManager project rename', () => { + beforeEach(() => { + vi.clearAllMocks(); + configureGlobalState(); + }); + + it('updates current, opened, and recent workspace records together', async () => { + const workspace = { + id: 'project-1', + name: 'Original project', + rootPath: 'D:/workspace/project-1', + workspaceKind: 'normal', + }; + const renamedWorkspace = { + ...workspace, + name: 'Renamed project', + }; + globalStateMocks.initializeWorkspaceStartupState.mockResolvedValue({ + cleanupRemovedCount: 0, + recentWorkspaces: [workspace], + openedWorkspaces: [workspace], + currentWorkspace: workspace, + legacyRemoteWorkspace: null, + }); + globalStateMocks.updateWorkspaceInfo.mockResolvedValue(renamedWorkspace); + listenMock.mockResolvedValue(() => undefined); + + const manager = await getFreshWorkspaceManager(); + await manager.initialize(); + + await expect(manager.renameWorkspace('project-1', ' Renamed project ')) + .resolves.toEqual(renamedWorkspace); + + expect(globalStateMocks.updateWorkspaceInfo).toHaveBeenCalledWith('project-1', { + name: 'Renamed project', + }); + const state = manager.getState(); + expect(state.currentWorkspace?.name).toBe('Renamed project'); + expect(state.openedWorkspaces.get('project-1')?.name).toBe('Renamed project'); + expect(state.recentWorkspaces[0]?.name).toBe('Renamed project'); + }); +}); diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts index f709322d73..b51056d522 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -1111,6 +1111,24 @@ class WorkspaceManager { } } + public async renameWorkspace(workspaceId: string, name: string): Promise { + try { + this.setError(null); + + const updatedWorkspace = await globalStateAPI.updateWorkspaceInfo(workspaceId, { + name: name.trim(), + }); + + this.applyWorkspaceRecordUpdate(updatedWorkspace); + return updatedWorkspace; + } catch (error) { + log.error('Failed to rename workspace', { workspaceId, error }); + const errorMessage = error instanceof Error ? error.message : String(error); + this.updateState({ error: errorMessage }, { type: 'workspace:error', error: errorMessage }); + throw error; + } + } + public async cleanupInvalidWorkspaces(): Promise { try { const removedCount = await globalStateAPI.cleanupInvalidWorkspaces(); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index be067ae65b..c9ed3959d2 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -361,6 +361,8 @@ "worktreeCreateOrOpenFailed": "Worktree was created, but opening it as a workspace failed: {{error}}", "worktreeDeleted": "Worktree deleted", "deleteWorktreeFailed": "Failed to delete worktree", + "renameFailed": "Failed to rename project", + "renamed": "Project renamed", "expandSessions": "Expand sessions", "collapseSessions": "Collapse sessions", "groups": { @@ -376,12 +378,23 @@ "initAgents": "Initialize AGENTS.md", "manageRelatedPaths": "Related directories", "manageProjectPermissions": "Project permissions", + "rename": "Rename project", "newWorktree": "New worktree", "deleteWorktree": "Delete worktree", "copyPath": "Copy path", "reveal": "Reveal in explorer", "close": "Close workspace" }, + "renameDialog": { + "title": "Rename project", + "description": "Changes the name shown in BitFun. The project directory and files stay unchanged.", + "placeholder": "Enter a project name", + "validation": { + "required": "Please enter a project name", + "tooLong": "Project name cannot exceed {{max}} characters", + "invalidCharacters": "Project name cannot contain control characters" + } + }, "relatedPaths": { "badge": "{{count}} linked", "dialog": { diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 27daafa579..624829bb96 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -361,6 +361,8 @@ "worktreeCreateOrOpenFailed": "worktree 已创建,但作为工作区打开失败:{{error}}", "worktreeDeleted": "已删除 worktree", "deleteWorktreeFailed": "删除 worktree 失败", + "renameFailed": "重命名项目失败", + "renamed": "项目已重命名", "expandSessions": "展开会话列表", "collapseSessions": "收起会话列表", "groups": { @@ -376,12 +378,23 @@ "initAgents": "初始化 AGENTS.md", "manageRelatedPaths": "关联目录", "manageProjectPermissions": "项目权限", + "rename": "重命名项目", "newWorktree": "新建 worktree", "deleteWorktree": "删除 worktree", "copyPath": "复制路径", "reveal": "在资源管理器中打开", "close": "关闭工作区" }, + "renameDialog": { + "title": "重命名项目", + "description": "仅修改 BitFun 中显示的项目名称,项目目录和文件不会改变。", + "placeholder": "输入项目名称", + "validation": { + "required": "请输入项目名称", + "tooLong": "项目名称不能超过 {{max}} 个字符", + "invalidCharacters": "项目名称不能包含控制字符" + } + }, "relatedPaths": { "badge": "{{count}} 关联", "dialog": { diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 6ec547d7b2..c09cf89313 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -361,6 +361,8 @@ "worktreeCreateOrOpenFailed": "worktree 已建立,但作為工作區開啟失敗:{{error}}", "worktreeDeleted": "已刪除 worktree", "deleteWorktreeFailed": "刪除 worktree 失敗", + "renameFailed": "重新命名專案失敗", + "renamed": "專案已重新命名", "expandSessions": "展開會話列表", "collapseSessions": "收起會話列表", "groups": { @@ -376,12 +378,23 @@ "initAgents": "初始化 AGENTS.md", "manageRelatedPaths": "關聯目錄", "manageProjectPermissions": "專案權限", + "rename": "重新命名專案", "newWorktree": "新增 worktree", "deleteWorktree": "刪除 worktree", "copyPath": "複製路徑", "reveal": "在資源管理器中開啟", "close": "關閉工作區" }, + "renameDialog": { + "title": "重新命名專案", + "description": "僅修改 BitFun 中顯示的專案名稱,專案目錄和檔案不會改變。", + "placeholder": "輸入專案名稱", + "validation": { + "required": "請輸入專案名稱", + "tooLong": "專案名稱不能超過 {{max}} 個字元", + "invalidCharacters": "專案名稱不能包含控制字元" + } + }, "relatedPaths": { "badge": "{{count}} 關聯", "dialog": {