Resolve workspace space row state during render (#3387)

This commit is contained in:
Neil
2026-05-30 14:50:25 -04:00
committed by GitHub
parent c62ddd750e
commit 5304ae118d
3 changed files with 108 additions and 35 deletions
@@ -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) ??
@@ -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)
})
})
@@ -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<string>
): Set<string> {
if (selectedIds.size === 0) {
return selectedIds
}
const validIds = new Set(rows.map((row) => row.worktreeId))
let changed = false
const nextIds = new Set<string>()
for (const id of selectedIds) {
if (validIds.has(id)) {
nextIds.add(id)
} else {
changed = true
}
}
return changed ? nextIds : selectedIds
}