diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 5fd9af33ad..15bd6ce7d2 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -387,6 +387,12 @@ pub struct DeleteAssistantWorkspaceRequest { pub workspace_id: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteWorkspaceRequest { + pub workspace_id: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResetAssistantWorkspaceRequest { @@ -1654,6 +1660,128 @@ pub async fn delete_assistant_workspace( Ok(()) } +/// Remove a workspace from the registry and delete its project directory on disk. +/// +/// This is the destructive counterpart to "close": close unloads but keeps the +/// workspace in the list and leaves files on disk; delete removes the workspace +/// from the list and recursively deletes its `root_path` directory. It: +/// 1. Closes the workspace if it is the active one. +/// 2. Unregisters a remote restore entry for remote workspaces. +/// 3. Deletes the workspace directory on disk for local workspaces. +/// 4. Removes the workspace from the in-memory registry and recent list. +/// 5. Reapplies (or clears) the active workspace context. +/// +/// Remote workspaces cannot be deleted locally; their `root_path` lives on the +/// remote host, so disk deletion is skipped for them. +#[tauri::command] +pub async fn delete_workspace( + state: State<'_, AppState>, + app: tauri::AppHandle, + request: DeleteWorkspaceRequest, +) -> Result<(), String> { + let workspace_info = state + .workspace_service + .get_workspace(&request.workspace_id) + .await + .ok_or_else(|| format!("Workspace not found: {}", request.workspace_id))?; + + let is_active_workspace = state + .workspace_service + .get_current_workspace() + .await + .map(|workspace| workspace.id == request.workspace_id) + .unwrap_or(false); + + if is_active_workspace { + state + .workspace_service + .close_workspace(&request.workspace_id) + .await + .map_err(|e| format!("Failed to close workspace before deletion: {}", e))?; + } + + if workspace_info.workspace_kind == WorkspaceKind::Remote { + if let Some(rw) = remote_workspace_from_info(&workspace_info) { + state + .unregister_remote_workspace_entry(&rw.connection_id, &rw.remote_path) + .await; + } + } + + let workspace_path_str = workspace_info.root_path.to_string_lossy().to_string(); + let is_remote = is_remote_path(&workspace_path_str).await + || workspace_info.workspace_kind == WorkspaceKind::Remote; + + if !is_remote && !workspace_path_str.trim().is_empty() { + if is_unsafe_workspace_deletion_path(&workspace_info.root_path) { + return Err(format!( + "Refusing to delete unsafe workspace path: {}", + workspace_info.root_path.display() + )); + } + + state + .filesystem_service + .delete_directory(&workspace_path_str, true) + .await + .map_err(|e| format!("Failed to delete workspace files: {}", e))?; + } + + state + .workspace_service + .remove_workspace(&request.workspace_id) + .await + .map_err(|e| format!("Failed to remove workspace state: {}", e))?; + + if let Some(current_workspace) = state.workspace_service.get_current_workspace().await { + apply_active_workspace_context(&state, &app, ¤t_workspace, None).await; + } else { + clear_active_workspace_context(&state, &app, None).await; + } + + if let Err(e) = state + .workspace_identity_watch_service + .sync_watched_workspaces() + .await + { + warn!( + "Failed to sync workspace identity watchers after workspace deletion: {}", + e + ); + } + + info!( + "Workspace deleted: workspace_id={}, kind={}, path={}", + request.workspace_id, + workspace_info.workspace_kind, + workspace_info.root_path.display() + ); + + Ok(()) +} + +/// Returns true if the path is unsafe to delete as a workspace directory. +/// +/// Blocks filesystem roots, relative paths, and any path with fewer than two +/// components (e.g. `/`, `C:\`) to avoid catastrophic deletions. +fn is_unsafe_workspace_deletion_path(path: &Path) -> bool { + if path.is_relative() { + return true; + } + + let mut components = path.components(); + let first = components.next(); + if first.is_none() { + return true; + } + + if components.next().is_none() { + return true; + } + + false +} + async fn clear_directory_contents(directory: &Path) -> Result<(), String> { tokio::fs::create_dir_all(directory).await.map_err(|e| { format!( diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index ec760f6e25..66ba2a24fa 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1103,6 +1103,7 @@ pub async fn _run() { delete_assistant_workspace, reset_assistant_workspace, close_workspace, + delete_workspace, set_active_workspace, reorder_opened_workspaces, get_current_workspace, 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 fe7f7d3051..2b55a0e7b6 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 @@ -83,6 +83,7 @@ const WorkspaceItem: React.FC = ({ setActiveWorkspace, closeWorkspaceById, deleteAssistantWorkspace, + deleteWorkspace, resetAssistantWorkspace, } = useWorkspaceContext(); const { switchLeftPanelTab } = useApp(); @@ -109,10 +110,12 @@ const WorkspaceItem: React.FC = ({ const [worktreeModalOpen, setWorktreeModalOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteWorktreeDialogOpen, setDeleteWorktreeDialogOpen] = useState(false); + const [deleteWorkspaceDialogOpen, setDeleteWorkspaceDialogOpen] = useState(false); const [resetDialogOpen, setResetDialogOpen] = useState(false); const [relatedPathsDialogOpen, setRelatedPathsDialogOpen] = useState(false); const [isDeletingAssistant, setIsDeletingAssistant] = useState(false); const [isDeletingWorktree, setIsDeletingWorktree] = useState(false); + const [isDeletingWorkspace, setIsDeletingWorkspace] = useState(false); const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); const [sessionsCollapsed, setSessionsCollapsed] = useState(false); const [searchIndexModalOpen, setSearchIndexModalOpen] = useState(false); @@ -475,6 +478,31 @@ const WorkspaceItem: React.FC = ({ } }, [closeWorkspaceById, t, workspace.id]); + const handleRequestDeleteWorkspace = useCallback(() => { + setMenuOpen(false); + setDeleteWorkspaceDialogOpen(true); + }, []); + + const handleConfirmDeleteWorkspace = useCallback(async () => { + if (isDeletingWorkspace) { + return; + } + + setIsDeletingWorkspace(true); + try { + await deleteWorkspace(workspace.id); + setDeleteWorkspaceDialogOpen(false); + notificationService.success(t('nav.workspaces.workspaceDeleted'), { duration: 2500 }); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.deleteWorkspaceFailed'), + { duration: 4000 } + ); + } finally { + setIsDeletingWorkspace(false); + } + }, [deleteWorkspace, isDeletingWorkspace, t, workspace.id]); + const handleOpenSessionBatchModal = useCallback(() => { setMenuOpen(false); setSessionBatchModalOpen(true); @@ -1288,6 +1316,15 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.close')} + , document.body )} @@ -1326,6 +1363,33 @@ const WorkspaceItem: React.FC = ({ confirmDanger preview={`${t('nav.workspaces.deleteWorktreeDialog.pathLabel')}\n${workspace.rootPath}`} /> + setDeleteWorkspaceDialogOpen(false)} + onConfirm={() => { void handleConfirmDeleteWorkspace(); }} + title={t('nav.workspaces.deleteWorkspaceDialog.title', { name: workspaceDisplayName })} + message={ + <> +
{t('nav.workspaces.deleteWorkspaceDialog.message')}
+
+ {workspace.rootPath} +
+ + } + confirmText={t('nav.workspaces.actions.deleteWorkspace')} + cancelText={t('actions.cancel')} + confirmDanger + /> {relatedPathsDialogOpen && ( = ({ className = '' }) => { isOpen={showNewProjectDialog} onClose={() => setShowNewProjectDialog(false)} onConfirm={handleConfirmNewProject} - defaultParentPath={hasWorkspace ? currentWorkspace?.rootPath : undefined} + defaultParentPath={ + hasWorkspace && currentWorkspace?.workspaceKind !== WorkspaceKind.Assistant + ? currentWorkspace?.rootPath + : '/storage/Users/currentUser/Documents' + } /> )} diff --git a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx index 2f0740a8c9..2af7f57981 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx @@ -112,6 +112,8 @@ export const ReadFileDisplay: React.FC = React.memo(({ status !== 'error' ); + const [shouldExpand, setShouldExpand] = useState(true); + if (status === 'error') { return null; } @@ -154,8 +156,6 @@ export const ReadFileDisplay: React.FC = React.memo(({ return null; }; - const [shouldExpand, setShouldExpand] = useState(true); - const handleMouseDown = () => { setShouldExpand(true); } diff --git a/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts b/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts index 5cd634b58a..a40e634fc6 100644 --- a/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts @@ -248,6 +248,16 @@ export class GlobalAPI { } } + async deleteWorkspace(workspaceId: string): Promise { + try { + await api.invoke('delete_workspace', { + request: { workspaceId } + }); + } catch (error) { + throw createTauriCommandError('delete_workspace', error, { workspaceId }); + } + } + async resetAssistantWorkspace(workspaceId: string): Promise { try { return await api.invoke('reset_assistant_workspace', { diff --git a/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts b/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts index 79aed11d10..398d31670c 100644 --- a/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts +++ b/src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts @@ -25,6 +25,7 @@ export interface WorkspaceContextValue extends WorkspaceState { closeWorkspace: () => Promise; closeWorkspaceById: (workspaceId: string) => Promise; deleteAssistantWorkspace: (workspaceId: string) => Promise; + deleteWorkspace: (workspaceId: string) => Promise; resetAssistantWorkspace: (workspaceId: string) => Promise; switchWorkspace: (workspace: WorkspaceInfo) => Promise; setActiveWorkspace: (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..6bbfdedfb6 100644 --- a/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx +++ b/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx @@ -39,6 +39,8 @@ export const WorkspaceProvider: React.FC = ({ children } closeWorkspaceById: async (workspaceId: string) => workspaceManager.closeWorkspaceById(workspaceId), deleteAssistantWorkspace: async (workspaceId: string) => workspaceManager.deleteAssistantWorkspace(workspaceId), + deleteWorkspace: async (workspaceId: string) => + workspaceManager.deleteWorkspace(workspaceId), resetAssistantWorkspace: async (workspaceId: string) => workspaceManager.resetAssistantWorkspace(workspaceId), switchWorkspace: async (workspace: WorkspaceInfo) => workspaceManager.switchWorkspace(workspace), @@ -87,6 +89,8 @@ export const WorkspaceProvider: React.FC = ({ children } closeWorkspaceById: async (workspaceId: string) => workspaceManager.closeWorkspaceById(workspaceId), deleteAssistantWorkspace: async (workspaceId: string) => workspaceManager.deleteAssistantWorkspace(workspaceId), + deleteWorkspace: async (workspaceId: string) => + workspaceManager.deleteWorkspace(workspaceId), resetAssistantWorkspace: async (workspaceId: string) => workspaceManager.resetAssistantWorkspace(workspaceId), switchWorkspace: async (workspace: WorkspaceInfo) => workspaceManager.switchWorkspace(workspace), @@ -231,6 +235,10 @@ export const WorkspaceProvider: React.FC = ({ children } return await workspaceManager.deleteAssistantWorkspace(workspaceId); }, []); + const deleteWorkspace = useCallback(async (workspaceId: string): Promise => { + return await workspaceManager.deleteWorkspace(workspaceId); + }, []); + const resetAssistantWorkspace = useCallback(async (workspaceId: string): Promise => { return await workspaceManager.resetAssistantWorkspace(workspaceId); }, []); @@ -295,6 +303,7 @@ export const WorkspaceProvider: React.FC = ({ children } closeWorkspace, closeWorkspaceById, deleteAssistantWorkspace, + deleteWorkspace, resetAssistantWorkspace, switchWorkspace, setActiveWorkspace, @@ -314,6 +323,7 @@ export const WorkspaceProvider: React.FC = ({ children } closeWorkspace, closeWorkspaceById, deleteAssistantWorkspace, + deleteWorkspace, resetAssistantWorkspace, switchWorkspace, setActiveWorkspace, diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts index b01a5fbd77..29222a83e2 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -740,6 +740,49 @@ class WorkspaceManager { } } + public async deleteWorkspace(workspaceId: string): Promise { + try { + this.setLoading(true); + this.setError(null); + + log.info('Deleting workspace', { workspaceId }); + + const removedWorkspace = this.state.openedWorkspaces.get(workspaceId); + if (removedWorkspace) { + await this.cancelRunningSessionsForWorkspace(removedWorkspace); + } + + await globalStateAPI.deleteWorkspace(workspaceId); + + if (removedWorkspace) { + const { flowChatStore } = await import('@/flow_chat/store/FlowChatStore'); + flowChatStore.removeSessionsForWorkspace(removedWorkspace); + } + + const [currentWorkspace, recentWorkspaces, openedWorkspaces] = await Promise.all([ + globalStateAPI.getCurrentWorkspace(), + globalStateAPI.getRecentWorkspaces(), + globalStateAPI.getOpenedWorkspaces(), + ]); + + this.updateWorkspaceState( + currentWorkspace, + recentWorkspaces, + openedWorkspaces, + false, + null, + { type: 'workspace:removed', workspaceId } + ); + + this.emit({ type: 'workspace:active-changed', workspace: currentWorkspace }); + } catch (error) { + log.error('Failed to delete workspace', { workspaceId, error }); + const errorMessage = error instanceof Error ? error.message : String(error); + this.updateState({ loading: false, error: errorMessage }, { type: 'workspace:error', error: errorMessage }); + throw error; + } + } + public async resetAssistantWorkspace(workspaceId: string): Promise { try { this.setLoading(true); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index ae5375c3ef..2f09f62046 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -336,6 +336,8 @@ "createAssistantFailed": "Failed to create personal assistant", "deleteAssistantFailed": "Failed to delete personal assistant", "assistantDeleted": "Personal assistant deleted", + "deleteWorkspaceFailed": "Failed to delete workspace", + "workspaceDeleted": "Workspace deleted", "resetWorkspaceFailed": "Failed to reset workspace", "workspaceReset": "Workspace reset", "reorderFailed": "Failed to reorder workspaces", @@ -364,6 +366,7 @@ "newSession": "New session", "newAssistant": "New personal assistant", "deleteAssistant": "Delete personal assistant", + "deleteWorkspace": "Delete workspace", "resetWorkspace": "Reset workspace", "initAgents": "Initialize AGENTS.md", "manageRelatedPaths": "Related directories", @@ -410,6 +413,11 @@ "title": "Delete {{name}}?", "message": "This will close the workspace and remove the worktree directory.", "pathLabel": "Worktree path" + }, + "deleteWorkspaceDialog": { + "title": "Delete {{name}}?", + "message": "This will permanently delete the workspace directory and all files inside it. This action cannot be undone.", + "pathLabel": "Workspace path" } }, "agents": { diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 8100d67ce3..a5ec8c74a9 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -336,6 +336,8 @@ "createAssistantFailed": "新建个人助理失败", "deleteAssistantFailed": "删除个人助理失败", "assistantDeleted": "已删除个人助理", + "deleteWorkspaceFailed": "删除工作区失败", + "workspaceDeleted": "已删除工作区", "resetWorkspaceFailed": "重置工作区失败", "workspaceReset": "已重置工作区", "reorderFailed": "调整工作区顺序失败", @@ -364,6 +366,7 @@ "newSession": "新建会话", "newAssistant": "新建个人助理", "deleteAssistant": "删除个人助理", + "deleteWorkspace": "删除工作区", "resetWorkspace": "重置工作区", "initAgents": "初始化 AGENTS.md", "manageRelatedPaths": "关联目录", @@ -410,6 +413,11 @@ "title": "删除 {{name}}?", "message": "此操作会关闭该工作区,并删除对应的 worktree 目录。", "pathLabel": "Worktree 路径" + }, + "deleteWorkspaceDialog": { + "title": "删除 {{name}}?", + "message": "此操作会永久删除该工作区目录及其中的所有文件,无法恢复。", + "pathLabel": "工作区路径" } }, "agents": { diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 4f02cc2e68..81f4c9b480 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -336,6 +336,8 @@ "createAssistantFailed": "新增個人助理失敗", "deleteAssistantFailed": "刪除個人助理失敗", "assistantDeleted": "已刪除個人助理", + "deleteWorkspaceFailed": "刪除工作區失敗", + "workspaceDeleted": "已刪除工作區", "resetWorkspaceFailed": "重設工作區失敗", "workspaceReset": "已重設工作區", "reorderFailed": "調整工作區順序失敗", @@ -364,6 +366,7 @@ "newSession": "新增會話", "newAssistant": "新增個人助理", "deleteAssistant": "刪除個人助理", + "deleteWorkspace": "刪除工作區", "resetWorkspace": "重設工作區", "initAgents": "初始化 AGENTS.md", "manageRelatedPaths": "關聯目錄", @@ -410,6 +413,11 @@ "title": "刪除 {{name}}?", "message": "此操作會關閉該工作區,並刪除對應的 worktree 目錄。", "pathLabel": "Worktree 路徑" + }, + "deleteWorkspaceDialog": { + "title": "刪除 {{name}}?", + "message": "此操作會永久刪除該工作區目錄及其中的所有檔案,無法復原。", + "pathLabel": "工作區路徑" } }, "agents": { diff --git a/src/web-ui/src/shared/types/global-state.ts b/src/web-ui/src/shared/types/global-state.ts index 4ac2b95169..3c2b2d3aa1 100644 --- a/src/web-ui/src/shared/types/global-state.ts +++ b/src/web-ui/src/shared/types/global-state.ts @@ -200,6 +200,7 @@ export interface GlobalStateAPI { ): Promise; createAssistantWorkspace(): Promise; deleteAssistantWorkspace(workspaceId: string): Promise; + deleteWorkspace(workspaceId: string): Promise; resetAssistantWorkspace(workspaceId: string): Promise; closeWorkspace(workspaceId: string): Promise; setActiveWorkspace(workspaceId: string): Promise; @@ -409,6 +410,10 @@ export function createGlobalStateAPI(): GlobalStateAPI { return await globalAPI.deleteAssistantWorkspace(workspaceId); }, + async deleteWorkspace(workspaceId: string): Promise { + return await globalAPI.deleteWorkspace(workspaceId); + }, + async resetAssistantWorkspace(workspaceId: string): Promise { return mapWorkspaceInfo(await globalAPI.resetAssistantWorkspace(workspaceId)); },