Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/apps/desktop/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, &current_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!(
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
setActiveWorkspace,
closeWorkspaceById,
deleteAssistantWorkspace,
deleteWorkspace,
resetAssistantWorkspace,
} = useWorkspaceContext();
const { switchLeftPanelTab } = useApp();
Expand All @@ -109,10 +110,12 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
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);
Expand Down Expand Up @@ -475,6 +478,31 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
}
}, [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);
Expand Down Expand Up @@ -1288,6 +1316,15 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
<FolderOpen size={13} />
<span className="bitfun-nav-panel__workspace-item-menu-label">{t('nav.workspaces.actions.close')}</span>
</button>
<button
type="button"
className="bitfun-nav-panel__workspace-item-menu-item is-danger"
onClick={handleRequestDeleteWorkspace}
disabled={isDeletingWorkspace}
>
<Trash2 size={13} />
<span className="bitfun-nav-panel__workspace-item-menu-label">{t('nav.workspaces.actions.deleteWorkspace')}</span>
</button>
</div>,
document.body
)}
Expand Down Expand Up @@ -1326,6 +1363,33 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
confirmDanger
preview={`${t('nav.workspaces.deleteWorktreeDialog.pathLabel')}\n${workspace.rootPath}`}
/>
<ConfirmDialog
isOpen={deleteWorkspaceDialogOpen}
onClose={() => setDeleteWorkspaceDialogOpen(false)}
onConfirm={() => { void handleConfirmDeleteWorkspace(); }}
title={t('nav.workspaces.deleteWorkspaceDialog.title', { name: workspaceDisplayName })}
message={
<>
<div>{t('nav.workspaces.deleteWorkspaceDialog.message')}</div>
<div style={{
marginTop: '8px',
padding: '6px 10px',
fontFamily: 'Consolas, Monaco, Courier New, monospace',
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-primary)',
background: 'var(--color-bg-subtle)',
borderRadius: 'var(--size-radius-base, 6px)',
wordBreak: 'break-all',
lineHeight: 1.5,
}}>
{workspace.rootPath}
</div>
</>
}
confirmText={t('nav.workspaces.actions.deleteWorkspace')}
cancelText={t('actions.cancel')}
confirmDanger
/>
{relatedPathsDialogOpen && (
<Suspense fallback={null}>
<WorkspaceRelatedPathsDialog
Expand Down
6 changes: 5 additions & 1 deletion src/web-ui/src/app/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,11 @@ const AppLayout: React.FC<AppLayoutProps> = ({ 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'
}
/>
</Suspense>
)}
Expand Down
4 changes: 2 additions & 2 deletions src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ export const ReadFileDisplay: React.FC<ToolCardProps> = React.memo(({
status !== 'error'
);

const [shouldExpand, setShouldExpand] = useState(true);

if (status === 'error') {
return null;
}
Expand Down Expand Up @@ -154,8 +156,6 @@ export const ReadFileDisplay: React.FC<ToolCardProps> = React.memo(({
return null;
};

const [shouldExpand, setShouldExpand] = useState(true);

const handleMouseDown = () => {
setShouldExpand(true);
}
Expand Down
10 changes: 10 additions & 0 deletions src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,16 @@ export class GlobalAPI {
}
}

async deleteWorkspace(workspaceId: string): Promise<void> {
try {
await api.invoke('delete_workspace', {
request: { workspaceId }
});
} catch (error) {
throw createTauriCommandError('delete_workspace', error, { workspaceId });
}
}

async resetAssistantWorkspace(workspaceId: string): Promise<WorkspaceInfo> {
try {
return await api.invoke('reset_assistant_workspace', {
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface WorkspaceContextValue extends WorkspaceState {
closeWorkspace: () => Promise<void>;
closeWorkspaceById: (workspaceId: string) => Promise<void>;
deleteAssistantWorkspace: (workspaceId: string) => Promise<void>;
deleteWorkspace: (workspaceId: string) => Promise<void>;
resetAssistantWorkspace: (workspaceId: string) => Promise<WorkspaceInfo>;
switchWorkspace: (workspace: WorkspaceInfo) => Promise<WorkspaceInfo>;
setActiveWorkspace: (workspaceId: string) => Promise<WorkspaceInfo>;
Expand Down
10 changes: 10 additions & 0 deletions src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ 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),
Expand Down Expand Up @@ -87,6 +89,8 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ 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),
Expand Down Expand Up @@ -231,6 +235,10 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ children }
return await workspaceManager.deleteAssistantWorkspace(workspaceId);
}, []);

const deleteWorkspace = useCallback(async (workspaceId: string): Promise<void> => {
return await workspaceManager.deleteWorkspace(workspaceId);
}, []);

const resetAssistantWorkspace = useCallback(async (workspaceId: string): Promise<WorkspaceInfo> => {
return await workspaceManager.resetAssistantWorkspace(workspaceId);
}, []);
Expand Down Expand Up @@ -295,6 +303,7 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ children }
closeWorkspace,
closeWorkspaceById,
deleteAssistantWorkspace,
deleteWorkspace,
resetAssistantWorkspace,
switchWorkspace,
setActiveWorkspace,
Expand All @@ -314,6 +323,7 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ children }
closeWorkspace,
closeWorkspaceById,
deleteAssistantWorkspace,
deleteWorkspace,
resetAssistantWorkspace,
switchWorkspace,
setActiveWorkspace,
Expand Down
Loading
Loading