mirror of
https://github.com/stablyai/orca.git
synced 2026-10-15 08:03:08 +00:00
Derive workspace space row state during render
This commit is contained in:
@@ -66,10 +66,13 @@ import { buildTreemapLayout, type TreemapRect } from './workspace-space-layout'
|
||||
import {
|
||||
filterWorkspaceSpaceRows,
|
||||
countWorkspaceSpaceActiveAgents,
|
||||
getWorkspaceSpaceInspectedWorktreeId,
|
||||
getSelectedDeletableWorkspaceIds,
|
||||
getVisibleDeletableWorkspaceIds,
|
||||
getWorkspaceSpaceGitStatusRefreshCandidates,
|
||||
getWorkspaceSpaceZoomWorktreeId,
|
||||
isWorkspaceSpaceRowReadyToDelete,
|
||||
pruneWorkspaceSpaceSelectedIds,
|
||||
sortWorkspaceSpaceRows,
|
||||
type WorkspaceSpaceSortDirection,
|
||||
type WorkspaceSpaceSortKey
|
||||
@@ -1105,6 +1108,28 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
|
||||
}, [cancelWorkspaceSpaceScan])
|
||||
|
||||
const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees])
|
||||
const currentInspectedWorktreeId = getWorkspaceSpaceInspectedWorktreeId(
|
||||
sourceRows,
|
||||
inspectedWorktreeId
|
||||
)
|
||||
const currentSelectedIds = pruneWorkspaceSpaceSelectedIds(sourceRows, selectedIds)
|
||||
const currentTreemapZoomWorktreeId = getWorkspaceSpaceZoomWorktreeId(
|
||||
sourceRows,
|
||||
treemapZoomWorktreeId
|
||||
)
|
||||
|
||||
// Why: this panel can keep rendering while a scan/delete removes rows.
|
||||
// Repair local row ids before delete actions, treemap, and details render.
|
||||
if (inspectedWorktreeId !== currentInspectedWorktreeId) {
|
||||
setInspectedWorktreeId(currentInspectedWorktreeId)
|
||||
}
|
||||
if (currentSelectedIds !== selectedIds) {
|
||||
setSelectedIds(currentSelectedIds)
|
||||
}
|
||||
if (treemapZoomWorktreeId !== currentTreemapZoomWorktreeId) {
|
||||
setTreemapZoomWorktreeId(currentTreemapZoomWorktreeId)
|
||||
}
|
||||
|
||||
const decisionDetailsByWorktreeId = useMemo(() => {
|
||||
// Why: active-agent freshness is time-based. The epoch bumps when fresh
|
||||
// hook entries cross the stale boundary so delete readiness recomputes.
|
||||
@@ -1270,16 +1295,18 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
|
||||
}, [refreshWorkspaceGitStatus, sourceRows])
|
||||
|
||||
const inspectedWorktree =
|
||||
rows.find((row) => row.worktreeId === inspectedWorktreeId) ??
|
||||
rows.find((row) => row.worktreeId === currentInspectedWorktreeId) ??
|
||||
rows.find((row) => row.status === 'ok') ??
|
||||
null
|
||||
const zoomedWorktree =
|
||||
sourceRows.find((row) => row.worktreeId === treemapZoomWorktreeId && row.status === 'ok') ??
|
||||
null
|
||||
sourceRows.find(
|
||||
(row) => row.worktreeId === currentTreemapZoomWorktreeId && row.status === 'ok'
|
||||
) ?? null
|
||||
const maxSize = Math.max(...rows.map((row) => row.sizeBytes), 0)
|
||||
const selectedDeletableIds = useMemo(
|
||||
() => getSelectedDeletableWorkspaceIds(rows, selectedIds, isWorktreeUnavailableForDelete),
|
||||
[isWorktreeUnavailableForDelete, rows, selectedIds]
|
||||
() =>
|
||||
getSelectedDeletableWorkspaceIds(rows, currentSelectedIds, isWorktreeUnavailableForDelete),
|
||||
[currentSelectedIds, isWorktreeUnavailableForDelete, rows]
|
||||
)
|
||||
const selectedDeletableIdSet = useMemo(
|
||||
() => new Set(selectedDeletableIds),
|
||||
@@ -1290,8 +1317,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) => currentSelectedIds.has(id))
|
||||
const someVisibleSelected = visibleDeletableIds.some((id) => currentSelectedIds.has(id))
|
||||
const visibleSelectionState = allVisibleSelected ? true : someVisibleSelected ? 'mixed' : false
|
||||
const isInitialScan = isScanning && !analysis
|
||||
const hasRows = sourceRows.length > 0
|
||||
@@ -1305,34 +1332,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'))
|
||||
@@ -1690,7 +1689,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
|
||||
key={worktree.worktreeId}
|
||||
worktree={worktree}
|
||||
maxSize={maxSize}
|
||||
selected={selectedIds.has(worktree.worktreeId)}
|
||||
selected={currentSelectedIds.has(worktree.worktreeId)}
|
||||
inspected={inspectedWorktree?.worktreeId === worktree.worktreeId}
|
||||
decisionDetails={
|
||||
decisionDetailsByWorktreeId.get(worktree.worktreeId) ??
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
filterWorkspaceSpaceRows,
|
||||
getSelectedDeletableWorkspaceIds,
|
||||
getVisibleDeletableWorkspaceIds,
|
||||
getWorkspaceSpaceInspectedWorktreeId,
|
||||
getWorkspaceSpaceGitStatusRefreshCandidates,
|
||||
getWorkspaceSpaceZoomWorktreeId,
|
||||
isWorkspaceSpaceRowReadyToDelete,
|
||||
pruneWorkspaceSpaceSelectedIds,
|
||||
sortWorkspaceSpaceRows
|
||||
} from './workspace-space-presentation'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
@@ -124,6 +127,32 @@ describe('workspace space presentation helpers', () => {
|
||||
).toEqual(['idle'])
|
||||
})
|
||||
|
||||
it('keeps the inspected workspace when it is still present', () => {
|
||||
const rows = [row({ worktreeId: 'failed', status: 'error' }), row({ worktreeId: 'ok' })]
|
||||
|
||||
expect(getWorkspaceSpaceInspectedWorktreeId(rows, 'failed')).toBe('failed')
|
||||
})
|
||||
|
||||
it('falls back inspected workspace to the first ok row', () => {
|
||||
const rows = [row({ worktreeId: 'missing-source', status: 'error' }), row({ worktreeId: 'ok' })]
|
||||
|
||||
expect(getWorkspaceSpaceInspectedWorktreeId(rows, 'gone')).toBe('ok')
|
||||
expect(getWorkspaceSpaceInspectedWorktreeId([], 'gone')).toBeNull()
|
||||
})
|
||||
|
||||
it('prunes selected workspace ids to rows still in the scan', () => {
|
||||
const rows = [row({ worktreeId: 'keep' }), row({ worktreeId: 'also-keep' })]
|
||||
|
||||
expect([...pruneWorkspaceSpaceSelectedIds(rows, new Set(['keep', 'gone']))]).toEqual(['keep'])
|
||||
})
|
||||
|
||||
it('keeps treemap zoom only for an ok workspace row', () => {
|
||||
const rows = [row({ worktreeId: 'ok' }), row({ worktreeId: 'failed', status: 'error' })]
|
||||
|
||||
expect(getWorkspaceSpaceZoomWorktreeId(rows, 'ok')).toBe('ok')
|
||||
expect(getWorkspaceSpaceZoomWorktreeId(rows, 'failed')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats open browser tabs as active workspace usage for deletion readiness', () => {
|
||||
const workspace = row({ worktreeId: 'with-browser', canDelete: true, status: 'ok' })
|
||||
|
||||
|
||||
@@ -238,6 +238,42 @@ export function getSelectedDeletableWorkspaceIds(
|
||||
.map((row) => row.worktreeId)
|
||||
}
|
||||
|
||||
export function getWorkspaceSpaceInspectedWorktreeId(
|
||||
rows: readonly WorkspaceSpaceWorktree[],
|
||||
currentId: string | null
|
||||
): string | null {
|
||||
if (currentId && rows.some((row) => row.worktreeId === currentId)) {
|
||||
return currentId
|
||||
}
|
||||
return rows.find((row) => row.status === 'ok')?.worktreeId ?? null
|
||||
}
|
||||
|
||||
export function getWorkspaceSpaceZoomWorktreeId(
|
||||
rows: readonly WorkspaceSpaceWorktree[],
|
||||
currentId: string | null
|
||||
): string | null {
|
||||
return currentId && rows.some((row) => row.worktreeId === currentId && row.status === 'ok')
|
||||
? currentId
|
||||
: null
|
||||
}
|
||||
|
||||
export function pruneWorkspaceSpaceSelectedIds(
|
||||
rows: readonly WorkspaceSpaceWorktree[],
|
||||
selectedIds: ReadonlySet<string>
|
||||
): Set<string> {
|
||||
const validIds = new Set(rows.map((row) => row.worktreeId))
|
||||
let changed = false
|
||||
const next = new Set<string>()
|
||||
for (const id of selectedIds) {
|
||||
if (validIds.has(id)) {
|
||||
next.add(id)
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : (selectedIds as Set<string>)
|
||||
}
|
||||
|
||||
export function getVisibleDeletableWorkspaceIds(
|
||||
rows: readonly WorkspaceSpaceWorktree[],
|
||||
isWorktreeDeleting: (worktreeId: string) => boolean = () => false
|
||||
|
||||
Reference in New Issue
Block a user