From 8ec8044d143925433611945df5a5af2b8bbd5dcf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 15 May 2026 19:35:52 -0700 Subject: [PATCH] Improve Space scan and delete states (#2025) --- .../status-bar/ResourceUsageStatusSegment.tsx | 49 +++++- .../status-bar/WorkspaceSpaceManagerPanel.tsx | 164 ++++++++++++++---- .../workspace-space-presentation.test.ts | 14 ++ .../workspace-space-presentation.ts | 20 ++- 4 files changed, 208 insertions(+), 39 deletions(-) diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index a904e9796..07c88f0c0 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -661,6 +661,9 @@ export function ResourceUsageStatusSegment({ const setActiveView = useAppStore((s) => s.setActiveView) const openModal = useAppStore((s) => s.openModal) const openSpacePage = useAppStore((s) => s.openSpacePage) + const activeView = useAppStore((s) => s.activeView) + const workspaceSpaceScannedAt = useAppStore((s) => s.workspaceSpaceAnalysis?.scannedAt ?? null) + const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning) const repos = useAppStore((s) => s.repos) const allWorktrees = useAllWorktrees() const activeRuntimeEnvironmentId = useAppStore( @@ -677,6 +680,9 @@ export function ResourceUsageStatusSegment({ const [sessionsError, setSessionsError] = useState(false) const [killConfirm, setKillConfirm] = useState(null) const [killing, setKilling] = useState(false) + const [spaceScanReady, setSpaceScanReady] = useState(false) + const previousSpaceScanningRef = useRef(workspaceSpaceScanning) + const lastSeenSpaceScanAtRef = useRef(workspaceSpaceScannedAt) // Why: this segment only understands the local Electron PTY/resource daemon. // While a runtime server is active, hiding local samples avoids showing or // killing sessions from the wrong machine. @@ -713,6 +719,28 @@ export function ResourceUsageStatusSegment({ } }) + // Why: Space scans can finish after the user backs out of the full page or + // closes this popover; the status-bar trigger becomes the handoff point. + useEffect(() => { + const scannedAt = workspaceSpaceScannedAt + const wasScanning = previousSpaceScanningRef.current + const scanCompleted = + wasScanning && + !workspaceSpaceScanning && + scannedAt !== null && + scannedAt !== lastSeenSpaceScanAtRef.current + + if (scanCompleted) { + lastSeenSpaceScanAtRef.current = scannedAt + setSpaceScanReady(!open && activeView !== 'space') + } else if (spaceScanReady && (open || activeView === 'space')) { + setSpaceScanReady(false) + lastSeenSpaceScanAtRef.current = scannedAt + } + + previousSpaceScanningRef.current = workspaceSpaceScanning + }, [activeView, open, spaceScanReady, workspaceSpaceScannedAt, workspaceSpaceScanning]) + // Poll memory + sessions when popover is open. Sessions also poll in the // background at a slower rate so the badge count stays reasonably fresh // without keeping the Memory IPC hot. @@ -1043,9 +1071,17 @@ export function ResourceUsageStatusSegment({ + ) : null} + + ) : null}
@@ -520,7 +582,7 @@ function WorkspaceRow({
- +
) @@ -550,6 +612,8 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace) const cancelWorkspaceSpaceScan = useAppStore((state) => state.cancelWorkspaceSpaceScan) const removeWorkspaceSpaceWorktrees = useAppStore((state) => state.removeWorkspaceSpaceWorktrees) + const removeWorktree = useAppStore((state) => state.removeWorktree) + const deleteStateByWorktreeId = useAppStore((state) => state.deleteStateByWorktreeId) const [query, setQuery] = useState('') const [onlyDeletable, setOnlyDeletable] = useState(false) const [sortKey, setSortKey] = useState('size') @@ -569,6 +633,10 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }, [cancelWorkspaceSpaceScan]) const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees]) + const isWorktreeDeleting = useCallback( + (worktreeId: string): boolean => deleteStateByWorktreeId[worktreeId]?.isDeleting ?? false, + [deleteStateByWorktreeId] + ) const rows = useMemo( () => @@ -589,16 +657,16 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { null const maxSize = Math.max(...rows.map((row) => row.sizeBytes), 0) const selectedDeletableIds = useMemo( - () => getSelectedDeletableWorkspaceIds(rows, selectedIds), - [rows, selectedIds] + () => getSelectedDeletableWorkspaceIds(rows, selectedIds, isWorktreeDeleting), + [isWorktreeDeleting, rows, selectedIds] ) const selectedDeletableIdSet = useMemo( () => new Set(selectedDeletableIds), [selectedDeletableIds] ) const visibleDeletableIds = useMemo( - () => rows.filter((row) => row.canDelete && row.status === 'ok').map((row) => row.worktreeId), - [rows] + () => getVisibleDeletableWorkspaceIds(rows, isWorktreeDeleting), + [isWorktreeDeleting, rows] ) const allVisibleSelected = visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => selectedIds.has(id)) @@ -686,6 +754,32 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }) } + const handleDeletedWorktrees = useCallback( + (deletedIds: readonly string[]): void => { + if (deletedIds.length === 0) { + return + } + removeWorkspaceSpaceWorktrees(deletedIds) + setInspectedWorktreeId((current) => + current && deletedIds.includes(current) ? null : current + ) + setTreemapZoomWorktreeId((current) => + current && deletedIds.includes(current) ? null : current + ) + setSelectedIds((current) => { + const next = new Set(current) + for (const id of deletedIds) { + next.delete(id) + } + return next + }) + toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', { + description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.` + }) + }, + [removeWorkspaceSpaceWorktrees] + ) + const deleteWorktrees = useCallback( (worktreeIds: readonly string[]): void => { if (worktreeIds.length === 0) { @@ -693,31 +787,33 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { } runWorktreeBatchDelete(worktreeIds, { forceConfirm: true, - onDeleted: (deletedIds) => { - removeWorkspaceSpaceWorktrees(deletedIds) - setInspectedWorktreeId((current) => - current && deletedIds.includes(current) ? null : current - ) - setTreemapZoomWorktreeId((current) => - current && deletedIds.includes(current) ? null : current - ) - setSelectedIds((current) => { - if (deletedIds.length === 0) { - return current - } - const next = new Set(current) - for (const id of deletedIds) { - next.delete(id) - } - return next - }) - toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', { - description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.` - }) - } + onDeleted: handleDeletedWorktrees }) }, - [removeWorkspaceSpaceWorktrees] + [handleDeletedWorktrees] + ) + + const forceDeleteWorktree = useCallback( + (worktree: WorkspaceSpaceWorktree): void => { + // Why: Space keeps normal deletes non-force so uncommitted work is not + // discarded silently; a failed row gets this explicit recovery path. + void removeWorktree(worktree.worktreeId, true) + .then((result) => { + if (!result.ok) { + toast.error('Force delete failed', { + description: result.error + }) + return + } + handleDeletedWorktrees([worktree.worktreeId]) + }) + .catch((error: unknown) => { + toast.error('Force delete failed', { + description: error instanceof Error ? error.message : String(error) + }) + }) + }, + [handleDeletedWorktrees, removeWorktree] ) const deleteSelected = (): void => { @@ -975,9 +1071,11 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { maxSize={maxSize} selected={selectedIds.has(worktree.worktreeId)} inspected={inspectedWorktree?.worktreeId === worktree.worktreeId} + deleteState={deleteStateByWorktreeId[worktree.worktreeId]} onToggleSelected={() => toggleSelection(worktree.worktreeId)} onInspect={() => setInspectedWorktreeId(worktree.worktreeId)} onDelete={() => deleteWorktrees([worktree.worktreeId])} + onForceDelete={() => forceDeleteWorktree(worktree)} /> )) )} diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 7c386b078..35bc15457 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -3,6 +3,7 @@ import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space- import { filterWorkspaceSpaceRows, getSelectedDeletableWorkspaceIds, + getVisibleDeletableWorkspaceIds, sortWorkspaceSpaceRows } from './workspace-space-presentation' @@ -76,4 +77,17 @@ describe('workspace space presentation helpers', () => { 'ok' ]) }) + + it('excludes rows that are already deleting from delete actions', () => { + const rows = [ + row({ worktreeId: 'idle', canDelete: true, status: 'ok' }), + row({ worktreeId: 'deleting', canDelete: true, status: 'ok' }) + ] + const isDeleting = (worktreeId: string): boolean => worktreeId === 'deleting' + + expect(getVisibleDeletableWorkspaceIds(rows, isDeleting)).toEqual(['idle']) + expect( + getSelectedDeletableWorkspaceIds(rows, new Set(['idle', 'deleting']), isDeleting) + ).toEqual(['idle']) + }) }) diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts index 4dd3c1d50..382508ca9 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -70,9 +70,25 @@ export function filterWorkspaceSpaceRows( export function getSelectedDeletableWorkspaceIds( rows: readonly WorkspaceSpaceWorktree[], - selectedIds: ReadonlySet + selectedIds: ReadonlySet, + isWorktreeDeleting: (worktreeId: string) => boolean = () => false ): string[] { return rows - .filter((row) => row.canDelete && row.status === 'ok' && selectedIds.has(row.worktreeId)) + .filter( + (row) => + row.canDelete && + row.status === 'ok' && + selectedIds.has(row.worktreeId) && + !isWorktreeDeleting(row.worktreeId) + ) + .map((row) => row.worktreeId) +} + +export function getVisibleDeletableWorkspaceIds( + rows: readonly WorkspaceSpaceWorktree[], + isWorktreeDeleting: (worktreeId: string) => boolean = () => false +): string[] { + return rows + .filter((row) => row.canDelete && row.status === 'ok' && !isWorktreeDeleting(row.worktreeId)) .map((row) => row.worktreeId) }