diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 0fa98ec3ac6..d09ff2b4c54 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -72,6 +72,9 @@ import { getVisibleDeletableWorkspaceIds, getWorkspaceSpaceGitStatusRefreshCandidates, isWorkspaceSpaceRowReadyToDelete, + pruneWorkspaceSpaceSelectedIds, + resolveWorkspaceSpaceInspectedWorktreeId, + resolveWorkspaceSpaceTreemapZoomWorktreeId, sortWorkspaceSpaceRows, type WorkspaceSpaceSortDirection, type WorkspaceSpaceSortKey @@ -1245,6 +1248,27 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { [onlyDeletable, query, sortDirection, sortKey, sourceRows] ) + const nextInspectedWorktreeId = resolveWorkspaceSpaceInspectedWorktreeId( + sourceRows, + inspectedWorktreeId + ) + const nextSelectedIds = pruneWorkspaceSpaceSelectedIds(sourceRows, selectedIds) + const nextTreemapZoomWorktreeId = resolveWorkspaceSpaceTreemapZoomWorktreeId( + sourceRows, + treemapZoomWorktreeId + ) + // Why: these ids are local UI state derived from the latest scan rows. Repair + // them before commit so stale selections cannot flash after a scan changes. + if (inspectedWorktreeId !== nextInspectedWorktreeId) { + setInspectedWorktreeId(nextInspectedWorktreeId) + } + if (nextSelectedIds !== selectedIds) { + setSelectedIds(nextSelectedIds) + } + if (treemapZoomWorktreeId !== nextTreemapZoomWorktreeId) { + setTreemapZoomWorktreeId(nextTreemapZoomWorktreeId) + } + useEffect(() => { const candidates = getWorkspaceSpaceGitStatusRefreshCandidates(sourceRows) if (candidates.length === 0) { @@ -1272,16 +1296,16 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }, [refreshWorkspaceGitStatus, sourceRows]) const inspectedWorktree = - rows.find((row) => row.worktreeId === inspectedWorktreeId) ?? + rows.find((row) => row.worktreeId === nextInspectedWorktreeId) ?? rows.find((row) => row.status === 'ok') ?? null const zoomedWorktree = - sourceRows.find((row) => row.worktreeId === treemapZoomWorktreeId && row.status === 'ok') ?? + sourceRows.find((row) => row.worktreeId === nextTreemapZoomWorktreeId && row.status === 'ok') ?? null const maxSize = getLargestWorkspaceSpaceRowSize(rows) const selectedDeletableIds = useMemo( - () => getSelectedDeletableWorkspaceIds(rows, selectedIds, isWorktreeUnavailableForDelete), - [isWorktreeUnavailableForDelete, rows, selectedIds] + () => getSelectedDeletableWorkspaceIds(rows, nextSelectedIds, isWorktreeUnavailableForDelete), + [isWorktreeUnavailableForDelete, nextSelectedIds, rows] ) const selectedDeletableIdSet = useMemo( () => new Set(selectedDeletableIds), @@ -1292,8 +1316,8 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { [isWorktreeUnavailableForDelete, rows] ) const allVisibleSelected = - visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => selectedIds.has(id)) - const someVisibleSelected = visibleDeletableIds.some((id) => selectedIds.has(id)) + visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => nextSelectedIds.has(id)) + const someVisibleSelected = visibleDeletableIds.some((id) => nextSelectedIds.has(id)) const visibleSelectionState = allVisibleSelected ? true : someVisibleSelected ? 'mixed' : false const isInitialScan = isScanning && !analysis const hasRows = sourceRows.length > 0 @@ -1307,34 +1331,6 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { [rows, selectedDeletableIdSet] ) - useEffect(() => { - if (!analysis) { - setInspectedWorktreeId(null) - return - } - setInspectedWorktreeId((current) => - current && analysis.worktrees.some((worktree) => worktree.worktreeId === current) - ? current - : (analysis.worktrees.find((worktree) => worktree.status === 'ok')?.worktreeId ?? null) - ) - }, [analysis]) - - useEffect(() => { - setSelectedIds((current) => { - const valid = new Set(sourceRows.map((row) => row.worktreeId)) - const next = new Set([...current].filter((id) => valid.has(id))) - return next.size === current.size ? current : next - }) - }, [sourceRows]) - - useEffect(() => { - setTreemapZoomWorktreeId((current) => - current && sourceRows.some((row) => row.worktreeId === current && row.status === 'ok') - ? current - : null - ) - }, [sourceRows]) - const toggleSort = (key: WorkspaceSpaceSortKey): void => { if (sortKey === key) { setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) @@ -1692,7 +1688,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { key={worktree.worktreeId} worktree={worktree} maxSize={maxSize} - selected={selectedIds.has(worktree.worktreeId)} + selected={nextSelectedIds.has(worktree.worktreeId)} inspected={inspectedWorktree?.worktreeId === worktree.worktreeId} decisionDetails={ decisionDetailsByWorktreeId.get(worktree.worktreeId) ?? 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 3fe0be6b74a..39b5bffe9f9 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 @@ -9,6 +9,9 @@ import { getVisibleDeletableWorkspaceIds, getWorkspaceSpaceGitStatusRefreshCandidates, isWorkspaceSpaceRowReadyToDelete, + pruneWorkspaceSpaceSelectedIds, + resolveWorkspaceSpaceInspectedWorktreeId, + resolveWorkspaceSpaceTreemapZoomWorktreeId, sortWorkspaceSpaceRows } from './workspace-space-presentation' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' @@ -239,4 +242,37 @@ describe('workspace space presentation helpers', () => { getWorkspaceSpaceGitStatusRefreshCandidates(rows).map((item) => item.worktreeId) ).toEqual(rows.map((item) => item.worktreeId)) }) + + it('resolves inspected worktree ids from the current scan rows', () => { + const rows = [ + row({ worktreeId: 'errored', status: 'error' }), + row({ worktreeId: 'ready', status: 'ok' }) + ] + + expect(resolveWorkspaceSpaceInspectedWorktreeId(rows, 'errored')).toBe('errored') + expect(resolveWorkspaceSpaceInspectedWorktreeId(rows, 'missing')).toBe('ready') + expect(resolveWorkspaceSpaceInspectedWorktreeId([], 'missing')).toBeNull() + }) + + it('keeps treemap zoom only for ready current scan rows', () => { + const rows = [ + row({ worktreeId: 'ready', status: 'ok' }), + row({ worktreeId: 'errored', status: 'error' }) + ] + + expect(resolveWorkspaceSpaceTreemapZoomWorktreeId(rows, 'ready')).toBe('ready') + expect(resolveWorkspaceSpaceTreemapZoomWorktreeId(rows, 'errored')).toBeNull() + expect(resolveWorkspaceSpaceTreemapZoomWorktreeId(rows, 'missing')).toBeNull() + }) + + it('prunes selected workspace ids that are absent from the current scan', () => { + const selectedIds = new Set(['ready', 'missing']) + const pruned = pruneWorkspaceSpaceSelectedIds([row({ worktreeId: 'ready' })], selectedIds) + + expect([...pruned]).toEqual(['ready']) + expect(pruned).not.toBe(selectedIds) + + const unchanged = pruneWorkspaceSpaceSelectedIds([row({ worktreeId: 'ready' })], pruned) + expect(unchanged).toBe(pruned) + }) }) 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 28efd04b67f..5f2b046a250 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -273,3 +273,44 @@ export function getVisibleDeletableWorkspaceIds( .filter((row) => row.canDelete && row.status === 'ok' && !isWorktreeDeleting(row.worktreeId)) .map((row) => row.worktreeId) } + +export function resolveWorkspaceSpaceInspectedWorktreeId( + rows: readonly WorkspaceSpaceWorktree[], + currentWorktreeId: string | null +): string | null { + if (currentWorktreeId && rows.some((row) => row.worktreeId === currentWorktreeId)) { + return currentWorktreeId + } + return rows.find((row) => row.status === 'ok')?.worktreeId ?? null +} + +export function resolveWorkspaceSpaceTreemapZoomWorktreeId( + rows: readonly WorkspaceSpaceWorktree[], + currentWorktreeId: string | null +): string | null { + return currentWorktreeId && + rows.some((row) => row.worktreeId === currentWorktreeId && row.status === 'ok') + ? currentWorktreeId + : null +} + +export function pruneWorkspaceSpaceSelectedIds( + rows: readonly WorkspaceSpaceWorktree[], + selectedIds: Set +): Set { + if (selectedIds.size === 0) { + return selectedIds + } + + const validIds = new Set(rows.map((row) => row.worktreeId)) + let changed = false + const nextIds = new Set() + for (const id of selectedIds) { + if (validIds.has(id)) { + nextIds.add(id) + } else { + changed = true + } + } + return changed ? nextIds : selectedIds +}