perf(workspace-cleanup): stream scan progress and size results

This commit is contained in:
Brennan Benson
2026-08-11 16:00:36 -07:00
parent a99a77a782
commit 1fb444fb4d
29 changed files with 1050 additions and 197 deletions
@@ -252,6 +252,46 @@ describe('workspace cleanup broad scan opt-in', () => {
).toMatchObject({ clean: true, checkedAt: expect.any(Number) })
})
it('batches fleet progress instead of emitting one IPC payload per row', async () => {
const worktrees = [
GIT_WORKTREES[0],
...Array.from(
{ length: 200 },
(_, index): GitWorktreeInfo => ({
path: `/repo-batch-${index}`,
head: `head-${index}`,
branch: `refs/heads/batch-${index}`,
isBare: false,
isMainWorktree: false
})
)
]
listRepoWorktreesMock.mockResolvedValue(worktrees)
const onProgress = vi.fn()
const result = await scanWorkspaceCleanup(
makeStore(),
{
scanId: 'fleet-scan',
includeAllWorkspaces: true,
skipGitWorktreeIds: worktrees.map((worktree) => `${REPO.id}::${worktree.path}`)
},
{ onProgress }
)
expect(onProgress.mock.calls.length).toBeLessThanOrEqual(2)
const progress = onProgress.mock.lastCall?.[0]
expect(progress).toMatchObject({
scannedWorktreeCount: result.candidates.length,
totalWorktreeCount: result.candidates.length,
candidateMode: 'append'
})
const streamedCandidates = onProgress.mock.calls.flatMap(([update]) => update.candidates)
expect(streamedCandidates.map((candidate) => candidate.worktreeId)).toEqual(
result.candidates.map((candidate) => candidate.worktreeId)
)
})
it('still forces a git read for a focused scan of a recent workspace', async () => {
const result = await scanWorkspaceCleanup(makeStore(), {
worktreeId: 'repo-1::/repo-recent',
@@ -0,0 +1,82 @@
import type {
WorkspaceCleanupCandidate,
WorkspaceCleanupScanError,
WorkspaceCleanupScanProgress
} from '../../shared/workspace-cleanup'
import { appendWorkspaceCleanupItems } from './workspace-cleanup-scan-primitives'
const WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS = 100
export type WorkspaceCleanupScanOptions = {
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
}
export type WorkspaceCleanupProgressEmitter = {
addDiscovered: (count: number) => void
addCandidate: (candidate: WorkspaceCleanupCandidate) => void
addErrors: (errors: WorkspaceCleanupScanError[]) => void
flush: () => void
}
/** Caps renderer projection churn while preserving an immediate final progress state. */
export function createWorkspaceCleanupProgressEmitter(
scanId: string | undefined,
scannedAt: number,
options: WorkspaceCleanupScanOptions
): WorkspaceCleanupProgressEmitter {
const errors: WorkspaceCleanupScanError[] = []
let pendingCandidates: WorkspaceCleanupCandidate[] = []
let totalWorktreeCount = 0
let scannedWorktreeCount = 0
let dirty = false
let emittedInitialDiscovery = false
let timer: NodeJS.Timeout | null = null
const flush = (): void => {
if (!scanId || !dirty) {
return
}
if (timer) {
clearTimeout(timer)
timer = null
}
dirty = false
const candidates = pendingCandidates
pendingCandidates = []
options.onProgress?.({
scanId,
scannedAt,
totalWorktreeCount,
scannedWorktreeCount,
candidates,
errors: [...errors],
candidateMode: 'append'
})
}
const schedule = (): void => {
if (!scanId) {
return
}
dirty = true
timer ??= setTimeout(flush, WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS)
}
return {
addDiscovered: (count) => {
totalWorktreeCount += count
schedule()
if (!emittedInitialDiscovery) {
emittedInitialDiscovery = true
flush()
}
},
addCandidate: (candidate) => {
pendingCandidates.push(candidate)
scannedWorktreeCount += 1
schedule()
},
addErrors: (newErrors) => {
appendWorkspaceCleanupItems(errors, newErrors)
schedule()
},
flush
}
}
+24 -69
View File
@@ -10,7 +10,6 @@ import type {
WorkspaceCleanupCandidate,
WorkspaceCleanupScanError,
WorkspaceCleanupScanArgs,
WorkspaceCleanupScanProgress,
WorkspaceCleanupScanResult
} from '../../shared/workspace-cleanup'
import { shouldScanBroadWorkspaceCleanupWorktree } from './workspace-cleanup-scan-eligibility'
@@ -34,25 +33,19 @@ import {
} from './workspace-cleanup-scan-primitives'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import { listWorkspaceCleanupFolderWorkspaces } from './workspace-cleanup-folder-workspaces'
import {
createWorkspaceCleanupProgressEmitter,
type WorkspaceCleanupScanOptions
} from './workspace-cleanup-progress-emitter'
const WORKTREE_SCAN_CONCURRENCY = 3
type WorkspaceCleanupScanOptions = {
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
}
type WorkspaceCleanupScanRepoProgress = {
onWorktreesDiscovered?: (count: number) => void
onCandidateScanned?: (candidate: WorkspaceCleanupCandidate) => void
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
}
type WorkspaceCleanupProgressEmitter = {
addDiscovered: (count: number) => void
addCandidate: (candidate: WorkspaceCleanupCandidate) => void
addErrors: (errors: WorkspaceCleanupScanError[]) => void
}
export async function scanWorkspaceCleanup(
store: Store,
args: WorkspaceCleanupScanArgs = {},
@@ -66,27 +59,30 @@ export async function scanWorkspaceCleanup(
const repos = parsedTarget
? store.getRepos().filter((repo) => repo.id === parsedTarget.repoId)
: store.getRepos()
const progress = createProgressEmitter(args.scanId, scannedAt, options)
const progress = createWorkspaceCleanupProgressEmitter(args.scanId, scannedAt, options)
const errors: WorkspaceCleanupScanResult['errors'] = []
const candidates: WorkspaceCleanupCandidate[] = []
for (const repo of repos) {
const result = await scanRepoWorkspaces({
store,
repo,
scannedAt,
targetWorktreeId: args.worktreeId,
includeAllWorkspaces: args.includeAllWorkspaces === true,
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []),
onWorktreesDiscovered: progress.addDiscovered,
onCandidateScanned: progress.addCandidate,
onErrors: progress.addErrors
})
appendWorkspaceCleanupItems(candidates, result.candidates)
appendWorkspaceCleanupItems(errors, result.errors)
try {
for (const repo of repos) {
const result = await scanRepoWorkspaces({
store,
repo,
scannedAt,
targetWorktreeId: args.worktreeId,
includeAllWorkspaces: args.includeAllWorkspaces === true,
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []),
onWorktreesDiscovered: progress.addDiscovered,
onCandidateScanned: progress.addCandidate,
onErrors: progress.addErrors
})
appendWorkspaceCleanupItems(candidates, result.candidates)
appendWorkspaceCleanupItems(errors, result.errors)
}
return { scannedAt, candidates, errors }
} finally {
progress.flush()
}
return { scannedAt, candidates, errors }
}
async function scanRepoWorkspaces(
@@ -280,44 +276,3 @@ function handleRepoWorktreeListError(args: {
onErrors?.(errors)
return { scannedAt, candidates: [], errors }
}
function createProgressEmitter(
scanId: string | undefined,
scannedAt: number,
options: WorkspaceCleanupScanOptions
): WorkspaceCleanupProgressEmitter {
const errors: WorkspaceCleanupScanError[] = []
let totalWorktreeCount = 0
let scannedWorktreeCount = 0
const emit = (
candidates: WorkspaceCleanupCandidate[],
candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot'
): void => {
if (!scanId) {
return
}
options.onProgress?.({
scanId,
scannedAt,
totalWorktreeCount,
scannedWorktreeCount,
candidates,
errors: [...errors],
candidateMode
})
}
return {
addDiscovered: (count) => {
totalWorktreeCount += count
emit([], 'append')
},
addCandidate: (candidate) => {
scannedWorktreeCount += 1
emit([candidate], 'append')
},
addErrors: (newErrors) => {
appendWorkspaceCleanupItems(errors, newErrors)
emit([], 'append')
}
}
}
+2 -2
View File
@@ -251,11 +251,11 @@ describe('workspace cleanup scan', () => {
(event as WorkspaceCleanupScanProgress).candidateMode === 'append' &&
(event as WorkspaceCleanupScanProgress).candidates.length > 0
)
expect(candidateProgress).toHaveLength(2)
expect(candidateProgress.every((event) => event.candidates.length === 1)).toBe(true)
expect(candidateProgress.length).toBeLessThanOrEqual(2)
const progressWorktreeIds = candidateProgress.flatMap((event) =>
event.candidates.map((candidate) => candidate.worktreeId)
)
expect(progressWorktreeIds).toHaveLength(2)
expect(progressWorktreeIds).toEqual(
expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b'])
)
+57
View File
@@ -153,6 +153,63 @@ describe('registerWorkspaceSpaceHandlers', () => {
expect(event.sender.send).toHaveBeenCalledWith('workspaceSpace:progress', progress)
})
it('batches completed measurements without dropping throttled rows', async () => {
const store = createStore()
let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined
let resolveScan!: (analysis: WorkspaceSpaceAnalysis) => void
analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => {
onProgress = options.onProgress
return new Promise<WorkspaceSpaceAnalysis>((resolve) => {
resolveScan = resolve
})
})
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
try {
registerWorkspaceSpaceHandlers(store)
const event = createEvent()
const promise = handlers.get('workspaceSpace:analyze')!(event)
const base = {
scanId: 'scan-1',
state: 'running' as const,
startedAt: 1,
updatedAt: 1,
totalRepoCount: 1,
scannedRepoCount: 0,
totalWorktreeCount: 2,
currentRepoDisplayName: 'orca',
currentWorktreeDisplayName: 'feature'
}
onProgress?.({ ...base, scannedWorktreeCount: 0 })
onProgress?.({
...base,
scannedWorktreeCount: 1,
completedMeasurements: [{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }]
})
onProgress?.({
...base,
scannedWorktreeCount: 2,
completedMeasurements: [{ worktreeId: 'b', status: 'ok', sizeBytes: 20 }]
})
resolveScan(createAnalysis(1))
await promise
expect(event.sender.send).toHaveBeenCalledTimes(2)
expect(event.sender.send).toHaveBeenLastCalledWith(
'workspaceSpace:progress',
expect.objectContaining({
scannedWorktreeCount: 2,
completedMeasurements: [
{ worktreeId: 'a', status: 'ok', sizeBytes: 10 },
{ worktreeId: 'b', status: 'ok', sizeBytes: 20 }
]
})
)
} finally {
now.mockRestore()
}
})
it('cancels the in-flight scan', async () => {
const store = createStore()
let signal: AbortSignal | undefined
+12 -2
View File
@@ -3,7 +3,8 @@ import type { Store } from '../persistence'
import type {
WorkspaceSpaceAnalysis,
WorkspaceSpaceAnalyzeResult,
WorkspaceSpaceScanProgress
WorkspaceSpaceScanProgress,
WorkspaceSpaceWorktreeMeasurement
} from '../../shared/workspace-space-types'
import {
analyzeWorkspaceSpace,
@@ -46,7 +47,11 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
currentWorktreeDisplayName: null
}
let lastProgressSentAt = 0
let pendingMeasurements: WorkspaceSpaceWorktreeMeasurement[] = []
const sendProgress = (progress: WorkspaceSpaceScanProgress): void => {
if (progress.completedMeasurements?.length) {
pendingMeasurements.push(...progress.completedMeasurements)
}
// Why: large fleets can report one progress event per worktree; keep
// the UI responsive without repainting the full Space page for each row.
const now = Date.now()
@@ -64,7 +69,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
}
lastProgressSentAt = now
if (!event.sender.isDestroyed()) {
event.sender.send('workspaceSpace:progress', progress)
const completedMeasurements = pendingMeasurements
pendingMeasurements = []
event.sender.send('workspaceSpace:progress', {
...progress,
...(completedMeasurements.length > 0 ? { completedMeasurements } : {})
})
}
}
// Why: large worktree fleets require real disk traversal; duplicate
+11
View File
@@ -221,6 +221,17 @@ describe('analyzeWorkspaceSpace', () => {
scannedRepoCount: 1,
scannedWorktreeCount: 1
})
expect(progress).toContainEqual(
expect.objectContaining({
completedMeasurements: [
expect.objectContaining({
worktreeId: `repo-1::${repoPath}`,
status: 'ok',
sizeBytes: expect.any(Number)
})
]
})
)
})
it('rejects when a scan is cancelled before it starts', async () => {
+12 -2
View File
@@ -703,8 +703,13 @@ function reportProgress(
updates: Partial<WorkspaceSpaceProgressState>,
onProgress: WorkspaceSpaceAnalyzeOptions['onProgress']
): void {
const completedMeasurements = updates.completedMeasurements
Object.assign(progress, updates, { updatedAt: Date.now() })
onProgress?.({ ...progress })
delete progress.completedMeasurements
onProgress?.({
...progress,
...(completedMeasurements?.length ? { completedMeasurements } : {})
})
}
async function scanRepo(
@@ -793,7 +798,12 @@ async function scanRepo(
)
reportProgress(
progress,
{ scannedWorktreeCount: progress.scannedWorktreeCount + 1 },
{
scannedWorktreeCount: progress.scannedWorktreeCount + 1,
completedMeasurements: [
{ worktreeId: row.worktreeId, status: row.status, sizeBytes: row.sizeBytes }
]
},
options.onProgress
)
return row
@@ -0,0 +1,154 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup'
import WorkspaceCleanupDialog from './WorkspaceCleanupDialog'
const probes = vi.hoisted(() => ({ facets: vi.fn() }))
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }))
vi.mock('sonner', () => ({ toast }))
vi.mock('./use-workspace-cleanup-facet-rows', () => ({
useWorkspaceCleanupFacetRows: () => {
probes.facets()
return {
rows: [],
selectableWorktreeIds: [],
matchedCount: 0,
totalCount: 0,
facetCounts: {
activity: 0,
size: 0,
status: 0,
agent: 0,
git: 0,
review: 0,
ticket: 0,
context: 0,
location: 0,
safety: 0
},
options: { workspaceStatuses: [], hostIds: [], repos: [], reviewProviders: [] },
reviewInfoByWorktreeId: new Map(),
sizeByWorktreeId: new Map(),
measuredSizeCount: 0
}
}
}))
vi.mock('@/components/ui/dialog', async () => {
const Passthrough = ({ children }: { children: ReactNode }) => <>{children}</>
return {
Dialog: Passthrough,
DialogContent: () => <div data-workspace-cleanup-content="true" />
}
})
const initialState = useAppStore.getInitialState()
let container: HTMLDivElement
let root: Root
function emptyScan(scannedAt: number): WorkspaceCleanupScanResult {
return { scannedAt, candidates: [], errors: [] }
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
async function openDialog(): Promise<void> {
await act(async () => useAppStore.getState().openModal('workspace-cleanup'))
await flush()
}
async function closeDialog(): Promise<void> {
await act(async () => useAppStore.getState().closeModal())
await flush()
}
function seedStore(scanWorkspaceCleanup: AppState['scanWorkspaceCleanup']): void {
useAppStore.setState(initialState, true)
useAppStore.setState({
activeModal: 'none',
workspaceCleanupScan: emptyScan(1),
workspaceCleanupProgress: null,
workspaceCleanupLoading: false,
workspaceCleanupError: null,
scanWorkspaceCleanup
})
}
describe('WorkspaceCleanupDialog mount gating', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
probes.facets.mockClear()
toast.error.mockClear()
toast.success.mockClear()
seedStore(vi.fn(async () => emptyScan(2)))
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
document.body.replaceChildren()
useAppStore.setState(initialState, true)
vi.clearAllTimers()
vi.useRealTimers()
})
it('drops heavy projections after the close animation', async () => {
await act(async () => root.render(<WorkspaceCleanupDialog />))
await flush()
expect(probes.facets).not.toHaveBeenCalled()
await openDialog()
expect(probes.facets).toHaveBeenCalled()
await closeDialog()
await act(async () => vi.advanceTimersByTimeAsync(300))
const hiddenCalls = probes.facets.mock.calls.length
for (let index = 0; index < 100; index += 1) {
await act(async () => {
useAppStore.setState({
workspaceCleanupScan: emptyScan(index + 10),
hostedReviewCache: { [`review-${index}`]: { data: null, fetchedAt: index } },
deleteStateByWorktreeId: {}
})
})
}
expect(probes.facets).toHaveBeenCalledTimes(hiddenCalls)
})
it('keeps scan completion ownership after heavy content unmounts', async () => {
let resolveScan!: (result: WorkspaceCleanupScanResult) => void
const scanPromise = new Promise<WorkspaceCleanupScanResult>((resolve) => {
resolveScan = resolve
})
const scanWorkspaceCleanup = vi.fn(() => scanPromise)
seedStore(scanWorkspaceCleanup)
await act(async () => root.render(<WorkspaceCleanupDialog />))
await openDialog()
await closeDialog()
await act(async () => vi.advanceTimersByTimeAsync(300))
expect(container.querySelector('[data-workspace-cleanup-content]')).toBeNull()
await act(async () => resolveScan(emptyScan(20)))
await flush()
expect(toast.success).toHaveBeenCalledWith(
'Workspace scan ready',
expect.objectContaining({ action: expect.objectContaining({ label: 'Review' }) })
)
})
})
@@ -27,25 +27,41 @@ import {
needsWorkspaceCleanupGitEvidence
} from './workspace-cleanup-git-evidence'
import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state'
import {
useWorkspaceCleanupDialogLifecycle,
type WorkspaceCleanupDialogLifecycle
} from './use-workspace-cleanup-dialog-lifecycle'
import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows'
import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evidence'
import { useWorkspaceCleanupRemoval } from './use-workspace-cleanup-removal'
import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order'
import { useWorkspaceCleanupScanLifecycle } from './use-workspace-cleanup-scan-lifecycle'
/** One filterable list of every workspace Orca knows about. */
export default function WorkspaceCleanupDialog(): React.JSX.Element {
const activeModal = useAppStore((s) => s.activeModal)
const closeModal = useAppStore((s) => s.closeModal)
export default function WorkspaceCleanupDialog(): React.JSX.Element | null {
const lifecycle = useWorkspaceCleanupDialogLifecycle()
if (!lifecycle.mountedContent) {
return null
}
return <WorkspaceCleanupDialogContent {...lifecycle} />
}
function WorkspaceCleanupDialogContent({
open,
loading,
closeModal,
selectedIds,
setSelectedIds,
removal,
startWorkspaceCleanupScan
}: WorkspaceCleanupDialogLifecycle): React.JSX.Element {
const scan = useAppStore((s) => s.workspaceCleanupScan)
const scanProgress = useAppStore((s) => s.workspaceCleanupProgress)
const loading = useAppStore((s) => s.workspaceCleanupLoading)
const error = useAppStore((s) => s.workspaceCleanupError)
const repos = useAppStore((s) => s.repos)
const markCandidateViewed = useAppStore((s) => s.markWorkspaceCleanupCandidateViewed)
const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates)
const refreshWorkspaceSpace = useAppStore((s) => s.refreshWorkspaceSpace)
const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
const workspaceSpaceProgress = useAppStore((s) => s.workspaceSpaceScanProgress)
const deletionPhaseByWorktreeId = useAppStore(
useShallow((s) => {
const phases: Record<string, WorkspaceCleanupDeletionPhase> = {}
@@ -62,53 +78,26 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
[deletionPhaseByWorktreeId]
)
const open = activeModal === 'workspace-cleanup'
const browse = useWorkspaceCleanupBrowseState()
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
const [expandedRowIds, setExpandedRowIds] = useState<Set<string>>(() => new Set())
const [rowsScrollElement, setRowsScrollElement] = useState<HTMLDivElement | null>(null)
const selectedDefaultsScanAtRef = useRef<number | null>(null)
const wasOpenRef = useRef(open)
// Why: a refresh completing mid-open must not clobber a selection the user
// already made (or was given) for this open — defaults apply at most once.
const defaultsAppliedForOpenRef = useRef(false)
const selectionTouchedRef = useRef(false)
const mountedRef = useMountedRef()
const deselectRemovedIds = useCallback((removedIds: readonly string[]) => {
if (removedIds.length === 0) {
return
useEffect(() => {
if (open && !wasOpenRef.current) {
defaultsAppliedForOpenRef.current = false
selectionTouchedRef.current = false
}
setSelectedIds((current) => {
const next = new Set(current)
for (const id of removedIds) {
next.delete(id)
}
return next
})
}, [])
const removal = useWorkspaceCleanupRemoval({ onDeselect: deselectRemovedIds, closeModal })
// Why: the controller object is rebuilt every render; effects must depend on
// its stable members or they re-run on every keystroke.
const { removalInFlightRef, resetForReopen, resetRowFailures } = removal
wasOpenRef.current = open
}, [open])
const onFreshOpen = useCallback(() => {
defaultsAppliedForOpenRef.current = false
selectionTouchedRef.current = false
// Why: filters and sort persist across reopens; only volatile row state
// resets, and not while a batch is still settling.
if (!removalInFlightRef.current) {
resetForReopen()
setSelectedIds(new Set())
}
}, [removalInFlightRef, resetForReopen])
const { startWorkspaceCleanupScan } = useWorkspaceCleanupScanLifecycle({
open,
loading,
removalInFlightRef,
resetRowFailures,
onFreshOpen
})
const { removalInFlightRef } = removal
const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates])
const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort)
@@ -179,7 +168,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
}
defaultsAppliedForOpenRef.current = true
setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds))
}, [deletingWorktreeIds, loading, removalInFlightRef, scan])
}, [deletingWorktreeIds, loading, removalInFlightRef, scan, setSelectedIds])
const pruneSelectionToVisibleRows = useEffectEvent(() => {
setSelectedIds((current) => {
@@ -230,7 +219,11 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
void dismissCandidates([candidate])
.then(() => {
if (mountedRef.current) {
deselectRemovedIds([candidate.worktreeId])
setSelectedIds((current) => {
const next = new Set(current)
next.delete(candidate.worktreeId)
return next
})
}
})
.catch((err: unknown) => {
@@ -245,17 +238,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
}
})
},
[deselectRemovedIds, dismissCandidates, mountedRef]
[dismissCandidates, mountedRef, setSelectedIds]
)
const toggleExpandedRow = useCallback((worktreeId: string) => {
setExpandedRowIds((current) => toggleSetMember(current, worktreeId))
}, [])
const toggleSelectedRow = useCallback((worktreeId: string) => {
selectionTouchedRef.current = true
setSelectedIds((current) => toggleSetMember(current, worktreeId))
}, [])
const toggleSelectedRow = useCallback(
(worktreeId: string) => {
selectionTouchedRef.current = true
setSelectedIds((current) => toggleSetMember(current, worktreeId))
},
[setSelectedIds]
)
const selectableWorktreeIds = useMemo(
() =>
@@ -269,7 +265,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
selectionTouchedRef.current = true
setSelectedIds(selectAll ? new Set(selectableWorktreeIds) : new Set())
},
[selectableWorktreeIds]
[selectableWorktreeIds, setSelectedIds]
)
// Why: stable per-row handlers so React.memo keeps unchanged CandidateRow
@@ -286,7 +282,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
setSelectedIds(new Set([candidate.worktreeId]))
openConfirmRemove([candidate])
},
[openConfirmRemove, removalInFlightRef]
[openConfirmRemove, removalInFlightRef, setSelectedIds]
)
const handleViewCandidate = useCallback(
@@ -343,6 +339,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
selectableCount={selectableWorktreeIds.length}
selectedCount={selectedCount}
spaceScanning={workspaceSpaceScanning}
spaceProgress={workspaceSpaceProgress}
gitPendingCount={gitEvidence.pendingWorktreeIds.size}
gitCheckedTotal={gitEvidence.totalCount}
onRunSpaceScan={runSpaceScan}
@@ -0,0 +1,82 @@
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'
import { useAppStore } from '@/store'
import {
useWorkspaceCleanupRemoval,
type WorkspaceCleanupRemovalController
} from './use-workspace-cleanup-removal'
import { useWorkspaceCleanupScanLifecycle } from './use-workspace-cleanup-scan-lifecycle'
const WORKSPACE_CLEANUP_CLOSE_LINGER_MS = 300
export type WorkspaceCleanupDialogLifecycle = {
open: boolean
mountedContent: boolean
loading: boolean
closeModal: () => void
selectedIds: Set<string>
setSelectedIds: Dispatch<SetStateAction<Set<string>>>
removal: WorkspaceCleanupRemovalController
startWorkspaceCleanupScan: (options?: { notifyWhenReady?: boolean }) => void
}
/** Keeps scan/removal ownership mounted while the closed dialog drops its projection tree. */
export function useWorkspaceCleanupDialogLifecycle(): WorkspaceCleanupDialogLifecycle {
const activeModal = useAppStore((s) => s.activeModal)
const closeModal = useAppStore((s) => s.closeModal)
const loading = useAppStore((s) => s.workspaceCleanupLoading)
const open = activeModal === 'workspace-cleanup'
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
const deselectRemovedIds = useCallback((removedIds: readonly string[]) => {
if (removedIds.length === 0) {
return
}
setSelectedIds((current) => {
const next = new Set(current)
for (const id of removedIds) {
next.delete(id)
}
return next
})
}, [])
const removal = useWorkspaceCleanupRemoval({ onDeselect: deselectRemovedIds, closeModal })
const { removalInFlightRef, resetForReopen, resetRowFailures } = removal
const onFreshOpen = useCallback(() => {
if (!removalInFlightRef.current) {
resetForReopen()
setSelectedIds(new Set())
}
}, [removalInFlightRef, resetForReopen])
const { startWorkspaceCleanupScan } = useWorkspaceCleanupScanLifecycle({
open,
loading,
removalInFlightRef,
resetRowFailures,
onFreshOpen
})
const [mountedContent, setMountedContent] = useState(open)
useEffect(() => {
if (open) {
setMountedContent(true)
return
}
const timer = window.setTimeout(
() => setMountedContent(false),
WORKSPACE_CLEANUP_CLOSE_LINGER_MS
)
return () => window.clearTimeout(timer)
}, [open])
return {
open,
mountedContent,
loading,
closeModal,
selectedIds,
setSelectedIds,
removal,
startWorkspaceCleanupScan
}
}
@@ -0,0 +1,182 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import {
createDefaultWorkspaceCleanupFilterState,
DEFAULT_WORKSPACE_CLEANUP_SORT
} from '../../../../shared/workspace-cleanup-filter-model'
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
import type * as FacetModule from './workspace-cleanup-facets'
import type * as QueryModule from './workspace-cleanup-query'
import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture'
const holders = vi.hoisted(() => ({ state: null as AppState | null }))
const counts = vi.hoisted(() => ({ facetCounts: 0, measured: 0, queries: 0 }))
vi.mock('@/store', () => ({
useAppStore: <T,>(selector: (state: AppState) => T): T => {
if (!holders.state) {
throw new Error('Missing test state')
}
return selector(holders.state)
}
}))
vi.mock('./workspace-cleanup-facets', async (importOriginal) => {
const actual = await importOriginal<typeof FacetModule>()
return {
...actual,
countWorkspaceCleanupMeasuredRows: (
...args: Parameters<typeof actual.countWorkspaceCleanupMeasuredRows>
) => {
counts.measured += 1
return actual.countWorkspaceCleanupMeasuredRows(...args)
}
}
})
vi.mock('./workspace-cleanup-query', async (importOriginal) => {
const actual = await importOriginal<typeof QueryModule>()
return {
...actual,
countWorkspaceCleanupFacetMatches: (
...args: Parameters<typeof actual.countWorkspaceCleanupFacetMatches>
) => {
counts.facetCounts += 1
return actual.countWorkspaceCleanupFacetMatches(...args)
},
runWorkspaceCleanupQuery: (...args: Parameters<typeof actual.runWorkspaceCleanupQuery>) => {
counts.queries += 1
return actual.runWorkspaceCleanupQuery(...args)
}
}
})
import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows'
function makeState(): AppState {
return {
worktreesByRepo: {},
hostedReviewCache: {},
repos: [],
settings: {},
workspaceStatuses: cloneDefaultWorkspaceStatuses(),
lastVisitedAtByWorktreeId: {},
agentStatusByPaneKey: {},
tabsByWorktree: {},
workspaceCleanupDismissals: {},
workspaceSpaceAnalysis: null,
workspaceSpaceMeasurements: []
} as unknown as AppState
}
describe('useWorkspaceCleanupFacetRows hot paths', () => {
beforeEach(() => {
holders.state = makeState()
counts.facetCounts = 0
counts.measured = 0
counts.queries = 0
})
it('does only the query work when the user types', () => {
const candidates = [makeFacetCandidate()]
const filters = createDefaultWorkspaceCleanupFilterState()
const view = renderHook(
({ currentFilters }) =>
useWorkspaceCleanupFacetRows({
candidates,
filters: currentFilters,
sort: DEFAULT_WORKSPACE_CLEANUP_SORT,
now: 1_700_000_000_000
}),
{ initialProps: { currentFilters: filters } }
)
const initialCounts = { ...counts }
const reviewIndex = view.result.current.reviewInfoByWorktreeId
view.rerender({ currentFilters: { ...filters, query: 'alpha' } })
expect(counts.queries).toBeGreaterThan(initialCounts.queries)
expect(counts.facetCounts).toBe(initialCounts.facetCounts)
expect(counts.measured).toBe(initialCounts.measured)
expect(view.result.current.reviewInfoByWorktreeId).toBe(reviewIndex)
})
it('keeps review joins stable during unrelated agent-status churn', () => {
const candidates = [makeFacetCandidate()]
const filters = createDefaultWorkspaceCleanupFilterState()
const view = renderHook(() =>
useWorkspaceCleanupFacetRows({
candidates,
filters,
sort: DEFAULT_WORKSPACE_CLEANUP_SORT,
now: 1_700_000_000_000
})
)
const reviewIndex = view.result.current.reviewInfoByWorktreeId
holders.state = { ...holders.state!, agentStatusByPaneKey: {} }
view.rerender()
expect(view.result.current.reviewInfoByWorktreeId).toBe(reviewIndex)
})
it('does not rebuild facets for count-only size progress', () => {
const candidates = [makeFacetCandidate()]
const filters = createDefaultWorkspaceCleanupFilterState()
const view = renderHook(() =>
useWorkspaceCleanupFacetRows({
candidates,
filters,
sort: DEFAULT_WORKSPACE_CLEANUP_SORT,
now: 1_700_000_000_000
})
)
const initialCounts = { ...counts }
holders.state = {
...holders.state!,
workspaceSpaceScanProgress: {
scanId: 'scan-1',
state: 'running',
startedAt: 1,
updatedAt: 2,
totalRepoCount: 1,
scannedRepoCount: 0,
totalWorktreeCount: 100,
scannedWorktreeCount: 20,
currentRepoDisplayName: 'Repo',
currentWorktreeDisplayName: 'alpha'
}
}
view.rerender()
expect(counts).toEqual(initialCounts)
})
it('projects streamed size measurements before the full scan completes', () => {
const candidates = [makeFacetCandidate()]
const filters = createDefaultWorkspaceCleanupFilterState()
const view = renderHook(() =>
useWorkspaceCleanupFacetRows({
candidates,
filters,
sort: DEFAULT_WORKSPACE_CLEANUP_SORT,
now: 1_700_000_000_000
})
)
expect(view.result.current.rows[0]?.sizeBytes).toBeNull()
holders.state = {
...holders.state!,
workspaceSpaceMeasurements: [
{ worktreeId: candidates[0]!.worktreeId, status: 'ok', sizeBytes: 4_096 }
]
}
view.rerender()
expect(view.result.current.rows[0]?.sizeBytes).toBe(4_096)
expect(view.result.current.measuredSizeCount).toBe(1)
})
})
@@ -70,22 +70,34 @@ export function useWorkspaceCleanupFacetRows({
agentStatusByPaneKey: s.agentStatusByPaneKey,
tabsByWorktree: s.tabsByWorktree,
dismissals: s.workspaceCleanupDismissals,
spaceWorktrees: s.workspaceSpaceAnalysis?.worktrees ?? null
spaceWorktrees: s.workspaceSpaceAnalysis?.worktrees ?? null,
spaceMeasurements: s.workspaceSpaceMeasurements
}))
)
const { hostedReviewCache, repos, settings, worktreesByRepo } = sources
const reviewInfoByWorktreeId = useMemo(() => {
const infos = new Map<string, WorkspaceCleanupReviewInfo>()
const reviewSources = { hostedReviewCache, repos, settings, worktreesByRepo }
for (const candidate of candidates) {
infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, sources))
infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, reviewSources))
}
return infos
}, [candidates, sources])
}, [candidates, hostedReviewCache, repos, settings, worktreesByRepo])
const sizeByWorktreeId = useMemo(
const completedSizeByWorktreeId = useMemo(
() => buildWorkspaceCleanupSizeIndex(sources.spaceWorktrees),
[sources.spaceWorktrees]
)
const sizeByWorktreeId = useMemo(() => {
if (sources.spaceMeasurements.length === 0) {
return completedSizeByWorktreeId
}
return new Map([
...completedSizeByWorktreeId,
...buildWorkspaceCleanupSizeIndex(sources.spaceMeasurements)
])
}, [completedSizeByWorktreeId, sources.spaceMeasurements])
const facets = useMemo(
() =>
@@ -102,17 +114,56 @@ export function useWorkspaceCleanupFacetRows({
reviewInfoByWorktreeId,
dismissedWorktreeIds: new Set(Object.keys(sources.dismissals))
}),
[candidates, now, reviewInfoByWorktreeId, sizeByWorktreeId, sources]
[
candidates,
now,
reviewInfoByWorktreeId,
sizeByWorktreeId,
sources.agentStatusByPaneKey,
sources.dismissals,
sources.lastVisitedAtByWorktreeId,
sources.tabsByWorktree,
sources.workspaceStatuses,
sources.worktreesByRepo
]
)
const result = useMemo(
() => runWorkspaceCleanupQuery(facets, { filters, sort }, now),
[facets, filters, now, sort]
)
const facetCounts = useMemo(
() => countWorkspaceCleanupFacetMatches(facets, filters, now),
[facets, filters, now]
const facetFilters = useMemo<WorkspaceCleanupFilterState>(
() => ({
query: '',
activity: filters.activity,
size: filters.size,
status: filters.status,
agent: filters.agent,
git: filters.git,
review: filters.review,
ticket: filters.ticket,
context: filters.context,
location: filters.location,
safety: filters.safety
}),
[
filters.activity,
filters.agent,
filters.context,
filters.git,
filters.location,
filters.review,
filters.safety,
filters.size,
filters.status,
filters.ticket
]
)
const facetCounts = useMemo(
() => countWorkspaceCleanupFacetMatches(facets, facetFilters, now),
[facets, facetFilters, now]
)
const measuredSizeCount = useMemo(() => countWorkspaceCleanupMeasuredRows(facets), [facets])
const options = useMemo<WorkspaceCleanupFacetOptions>(
() => ({
@@ -144,6 +195,6 @@ export function useWorkspaceCleanupFacetRows({
options,
reviewInfoByWorktreeId,
sizeByWorktreeId,
measuredSizeCount: countWorkspaceCleanupMeasuredRows(facets)
measuredSizeCount
}
}
@@ -59,7 +59,7 @@ export function useWorkspaceCleanupScanLifecycle({
toast.success(
translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63',
'Inactive workspace scan ready'
'Workspace scan ready'
),
{
description: formatWorkspaceCleanupReadyToast(
@@ -9,6 +9,7 @@ import {
import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices'
import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header'
import type { WorkspaceSpaceScanProgress } from '../../../../shared/workspace-space-types'
export function WorkspaceCleanupBrowseToolbar({
browse,
@@ -16,6 +17,7 @@ export function WorkspaceCleanupBrowseToolbar({
selectableCount,
selectedCount,
spaceScanning,
spaceProgress,
gitPendingCount,
gitCheckedTotal,
onRunSpaceScan,
@@ -26,6 +28,7 @@ export function WorkspaceCleanupBrowseToolbar({
selectableCount: number
selectedCount: number
spaceScanning: boolean
spaceProgress: WorkspaceSpaceScanProgress | null
gitPendingCount: number
gitCheckedTotal: number
onRunSpaceScan: () => void
@@ -48,6 +51,8 @@ export function WorkspaceCleanupBrowseToolbar({
sizeScan={{
measuredCount: facetRows.measuredSizeCount,
scanning: spaceScanning,
scannedCount: spaceProgress?.scannedWorktreeCount ?? 0,
totalCount: spaceProgress?.totalWorktreeCount ?? 0,
onRun: onRunSpaceScan
}}
gitEvidence={{ pendingCount: gitPendingCount, totalCount: gitCheckedTotal }}
@@ -61,7 +66,7 @@ export function WorkspaceCleanupBrowseToolbar({
onToggleSortField={browse.toggleSortField}
onToggleSelectAll={onToggleSelectAll}
/>
{browse.sort.field === 'size' && facetRows.measuredSizeCount === 0 ? (
{browse.sort.field === 'size' && facetRows.measuredSizeCount === 0 && !spaceScanning ? (
<WorkspaceCleanupEmptyState
title={translate(
'components.workspace.cleanup.browse.noSizes',
@@ -24,6 +24,7 @@ const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8
export function WorkspaceCleanupCandidateList<Row extends WorkspaceCleanupListRow>({
rows,
renderRow,
estimatedRowHeight = WORKSPACE_CLEANUP_ROW_ESTIMATE_PX,
// Why: a state-held element, not a ref — the ScrollArea viewport is not
// attached when this component first mounts, so a ref would leave the
// virtualizer unobserved until some unrelated re-render.
@@ -31,6 +32,7 @@ export function WorkspaceCleanupCandidateList<Row extends WorkspaceCleanupListRo
}: {
rows: readonly Row[]
renderRow: (row: Row, index: number) => React.ReactNode
estimatedRowHeight?: number
scrollElement: HTMLDivElement | null
}): React.JSX.Element {
const virtualize = rows.length >= WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS
@@ -39,7 +41,7 @@ export function WorkspaceCleanupCandidateList<Row extends WorkspaceCleanupListRo
count: rows.length,
enabled: virtualize && scrollElement !== null,
getScrollElement: () => scrollElement,
estimateSize: () => WORKSPACE_CLEANUP_ROW_ESTIMATE_PX,
estimateSize: () => estimatedRowHeight,
overscan: WORKSPACE_CLEANUP_ROW_OVERSCAN,
// Why: stable worktree keys let the virtualizer carry row identity across
// scan refreshes instead of remounting the window on every streamed row.
@@ -0,0 +1,75 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture'
import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove'
const listProbe = vi.hoisted(() => vi.fn())
vi.mock('./workspace-cleanup-candidate-list', () => ({
WorkspaceCleanupCandidateList: <Row,>({
rows,
renderRow
}: {
rows: readonly Row[]
renderRow: (row: Row, index: number) => ReactNode
}) => {
listProbe(rows.length)
return <>{rows.slice(0, 5).map(renderRow)}</>
}
}))
vi.mock('@/components/ui/dialog', () => ({
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
vi.mock('@/components/ui/scroll-area', () => ({
ScrollArea: ({ children }: { children: ReactNode }) => <div>{children}</div>
}))
let container: HTMLDivElement
let root: Root
describe('WorkspaceCleanupConfirmRemove', () => {
beforeEach(() => {
listProbe.mockClear()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => root.unmount())
document.body.replaceChildren()
})
it('routes fleet-sized confirmations through the bounded row list', () => {
const candidates = Array.from({ length: 2_727 }, (_, index) =>
makeFacetCandidate({
worktreeId: `repo-1::/repo/worktree-${index}`,
displayName: `Workspace ${index}`,
path: `/repo/worktree-${index}`
})
)
act(() => {
root.render(
<WorkspaceCleanupConfirmRemove
candidates={candidates}
reviewInfoByWorktreeId={new Map()}
progress={null}
onBack={vi.fn()}
onCancel={vi.fn()}
onConfirm={vi.fn()}
/>
)
})
expect(listProbe).toHaveBeenCalledWith(candidates.length)
expect(container.querySelectorAll('span.truncate.text-sm.font-medium')).toHaveLength(5)
})
})
@@ -18,6 +18,9 @@ import {
import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation'
import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time'
import { StatusPill } from './workspace-cleanup-status-pill'
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
const CONFIRM_REMOVE_ROW_ESTIMATE_PX = 76
const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = {
hasReview: false,
@@ -42,6 +45,7 @@ export function WorkspaceCleanupConfirmRemove({
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
const [scrollElement, setScrollElement] = React.useState<HTMLDivElement | null>(null)
const count = candidates.length
const deleting = progress !== null
const progressValue = progress
@@ -120,15 +124,20 @@ export function WorkspaceCleanupConfirmRemove({
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
{candidates.map((candidate, index) => (
<ConfirmRemoveRow
key={candidate.worktreeId}
candidate={candidate}
reviewInfo={reviewInfoByWorktreeId.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO}
last={index === candidates.length - 1}
/>
))}
<ScrollArea className="min-h-0 flex-1" viewportRef={setScrollElement}>
<WorkspaceCleanupCandidateList
rows={candidates}
scrollElement={scrollElement}
estimatedRowHeight={CONFIRM_REMOVE_ROW_ESTIMATE_PX}
renderRow={(candidate, index) => (
<ConfirmRemoveRow
key={candidate.worktreeId}
candidate={candidate}
reviewInfo={reviewInfoByWorktreeId.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO}
last={index === candidates.length - 1}
/>
)}
/>
</ScrollArea>
</div>
<DialogFooter className="border-t border-border px-5 py-3">
@@ -3,32 +3,38 @@ import { AlertTriangle, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup'
import { formatWorkspaceCleanupScanProgress } from './workspace-cleanup-scan-notice'
export function WorkspaceCleanupInitialScanBanner({
progress
}: {
progress: WorkspaceCleanupScanProgress | null
}): React.JSX.Element {
const title =
progress && progress.totalWorktreeCount > 0
? translate(
'components.workspace.cleanup.scan.progress',
'Scanning workspaces ({{value0}}/{{value1}})',
{
value0: progress.scannedWorktreeCount,
value1: progress.totalWorktreeCount
}
)
: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968',
'Scanning workspaces'
)
return (
<div className="flex items-start gap-2 border-b border-border bg-muted/25 px-5 py-3">
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-muted-foreground" />
<div className="min-w-0">
<div className="text-xs font-medium text-foreground">
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968',
'Checking inactive workspaces'
)}
</div>
<div className="text-xs font-medium text-foreground">{title}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.47123d0108',
'Scanning inactive workspaces. You can close this and come back.'
'Collecting workspace information. You can close this and come back.'
)}
</div>
<div className="mt-1 text-xs font-medium text-muted-foreground">
{formatWorkspaceCleanupScanProgress(progress)}
</div>
</div>
</div>
)
@@ -2,7 +2,10 @@ import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
import type { Worktree, WorkspaceStatusDefinition } from '../../../../shared/types'
import { getWorkspaceStatus } from '../../../../shared/workspace-statuses'
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
import type {
WorkspaceSpaceWorktree,
WorkspaceSpaceWorktreeMeasurement
} from '../../../../shared/workspace-space-types'
import {
canSelectWorkspaceCleanupCandidate,
type WorkspaceCleanupBlocker,
@@ -163,7 +166,10 @@ export function buildWorkspaceCleanupFacetList(
/** Only `ok` scans carry a trustworthy byte count; everything else stays unsized. */
export function buildWorkspaceCleanupSizeIndex(
worktrees: readonly WorkspaceSpaceWorktree[] | null | undefined
worktrees:
| readonly (WorkspaceSpaceWorktree | WorkspaceSpaceWorktreeMeasurement)[]
| null
| undefined
): Map<string, number> {
const index = new Map<string, number>()
for (const entry of worktrees ?? []) {
@@ -0,0 +1,51 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
afterEach(cleanup)
describe('WorkspaceCleanupFilterBar', () => {
it('shows determinate size-scan progress', () => {
render(
<WorkspaceCleanupFilterBar
facetProps={{
filters: createDefaultWorkspaceCleanupFilterState(),
counts: {
activity: 0,
size: 0,
status: 0,
agent: 0,
git: 0,
review: 0,
ticket: 0,
context: 0,
location: 0,
safety: 0
},
totalCount: 100,
options: { workspaceStatuses: [], hostIds: [], repos: [], reviewProviders: [] },
onPatch: vi.fn()
}}
activeFacetGroupCount={0}
matchedCount={100}
hasActiveFilters={false}
sizeScan={{
measuredCount: 23,
scanning: true,
scannedCount: 23,
totalCount: 100,
onRun: vi.fn()
}}
gitEvidence={{ pendingCount: 0, totalCount: 0 }}
onQueryChange={vi.fn()}
onClearFilters={vi.fn()}
/>
)
expect(
(screen.getByRole('button', { name: 'Measuring 23/100' }) as HTMLButtonElement).disabled
).toBe(true)
})
})
@@ -12,6 +12,8 @@ import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-
export type WorkspaceCleanupSizeScanState = {
measuredCount: number
scanning: boolean
scannedCount: number
totalCount: number
onRun: () => void
}
@@ -111,20 +113,22 @@ export function WorkspaceCleanupFilterBar({
</span>
) : null}
{sizeScan.measuredCount === 0 ? (
<Button
variant="outline"
size="sm"
className="shrink-0"
disabled={sizeScan.scanning}
onClick={sizeScan.onRun}
>
{sizeScan.scanning || sizeScan.measuredCount === 0 ? (
<Button variant="outline" size="sm" disabled={sizeScan.scanning} onClick={sizeScan.onRun}>
{sizeScan.scanning ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<HardDrive className="size-3.5" />
)}
{translate('components.workspace.cleanup.browse.measureSizes', 'Measure sizes')}
{sizeScan.scanning
? sizeScan.totalCount > 0
? translate(
'components.workspace.cleanup.browse.measuringSizesProgress',
'Measuring {{value0}}/{{value1}}',
{ value0: sizeScan.scannedCount, value1: sizeScan.totalCount }
)
: translate('components.workspace.cleanup.browse.measuringSizes', 'Measuring sizes')
: translate('components.workspace.cleanup.browse.measureSizes', 'Measure sizes')}
</Button>
) : null}
</div>
@@ -1,4 +1,4 @@
import { getWorktreeMapFromState } from '@/store/selectors'
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
import type { AppState } from '@/store/types'
import { translate } from '@/i18n/i18n'
@@ -52,7 +52,7 @@ export function getWorkspaceCleanupReviewInfo(
state: WorkspaceCleanupRendererStateInputs
): WorkspaceCleanupReviewInfo {
const worktree = getWorktreeMapFromState(state).get(candidate.worktreeId) ?? null
const repo = state.repos.find((entry) => entry.id === candidate.repoId) ?? null
const repo = getRepoMapFromState(state).get(candidate.repoId) ?? null
const hostedReview = getCachedHostedReview(candidate, worktree, repo, state)
if (hostedReview) {
return {
@@ -42,6 +42,15 @@ export function matchesWorkspaceCleanupFilters(
now: number = Date.now()
): boolean {
const query = filters.query.trim().toLowerCase()
return matchesWorkspaceCleanupFiltersWithQuery(facets, filters, now, query)
}
function matchesWorkspaceCleanupFiltersWithQuery(
facets: WorkspaceCleanupFacets,
filters: WorkspaceCleanupFilterState,
now: number,
query: string
): boolean {
if (query && !facets.searchText.includes(query)) {
return false
}
@@ -64,7 +73,10 @@ export function filterWorkspaceCleanupFacets(
filters: WorkspaceCleanupFilterState,
now: number = Date.now()
): WorkspaceCleanupFacets[] {
return facets.filter((entry) => matchesWorkspaceCleanupFilters(entry, filters, now))
const query = filters.query.trim().toLowerCase()
return facets.filter((entry) =>
matchesWorkspaceCleanupFiltersWithQuery(entry, filters, now, query)
)
}
export function runWorkspaceCleanupQuery(
@@ -41,7 +41,7 @@ export function formatWorkspaceCleanupScanProgress(
if (!progress || progress.scannedWorktreeCount === 0) {
return translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe',
'Finding inactive workspaces...'
'Finding workspaces...'
)
}
return translate(
@@ -52,15 +52,15 @@ export function formatWorkspaceCleanupScanProgress(
}
export function formatWorkspaceCleanupReadyToast(
inactiveCount: number,
workspaceCount: number,
suggestedCount: number
): string {
if (inactiveCount === 0) {
if (workspaceCount === 0) {
return 'No workspaces found.'
}
const workspaceNoun = inactiveCount === 1 ? 'workspace' : 'workspaces'
const workspaceNoun = workspaceCount === 1 ? 'workspace' : 'workspaces'
const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions'
return `${inactiveCount} ${workspaceNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.`
return `${workspaceCount} ${workspaceNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.`
}
function formatScanErrorRepoName(
+8 -6
View File
@@ -2580,7 +2580,7 @@
"2b31bf68de": "inactive",
"ac5ba84cc1": "selected",
"8b74d4ea6e": "Scanning worktrees and git state, then combining open tab, terminal, live agent, and remote availability signals before suggesting deletions.",
"7eee951968": "Checking workspace safety",
"7eee951968": "Scanning workspaces",
"191f0bc98e": "Close",
"7ae2ad30f4": "Refresh",
"e0b5a4deaa": "Review inactive workspaces before deleting their local files and Orca state.",
@@ -2596,16 +2596,16 @@
"ee81adfcef": "View",
"4d0b72481c": "Ignore",
"9cc26c019d": "Remove",
"0e2d235c63": "Inactive workspace scan ready",
"0e2d235c63": "Workspace scan ready",
"4a35c08764": "Review",
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
"47123d0108": "Collecting workspace information. You can close this and come back.",
"9a3be9f2df": "Scanning workspaces. New rows appear here as they finish. You can close this and come back.",
"3d957ff117": "No workspaces match these filters.",
"e94b1f8bb4": "Clear filters",
"efb3843e75": "Filter and sort workspaces",
"93b7381d50": "Filters",
"a615e24679": "Sort",
"4cc5b73efe": "Finding inactive workspaces...",
"4cc5b73efe": "Finding workspaces...",
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
"searchPlaceholder": "Search workspaces",
"ageFilter": "Age",
@@ -15484,7 +15484,9 @@
"notMeasured": "Not measured",
"openWorkspaceNamed": "Open {{value0}}",
"openWorkspace": "Open workspace",
"workspaceStatus": "Workspace status: {{value0}}"
"workspaceStatus": "Workspace status: {{value0}}",
"measuringSizesProgress": "Measuring {{value0}}/{{value1}}",
"measuringSizes": "Measuring sizes"
}
}
},
@@ -87,6 +87,37 @@ function makeAnalysis(): WorkspaceSpaceAnalysis {
}
describe('workspace space slice', () => {
it('keeps streamed measurements across later progress updates', () => {
const store = createWorkspaceSpaceTestStore()
store.setState({ workspaceSpaceScanning: true })
const progress = {
scanId: 'scan-1',
state: 'running' as const,
startedAt: 1,
updatedAt: 1,
totalRepoCount: 1,
scannedRepoCount: 0,
totalWorktreeCount: 2,
scannedWorktreeCount: 1,
currentRepoDisplayName: 'orca',
currentWorktreeDisplayName: 'feature'
}
store.getState().applyWorkspaceSpaceProgress({
...progress,
completedMeasurements: [{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }]
})
store.getState().applyWorkspaceSpaceProgress({
...progress,
scannedWorktreeCount: 2,
currentWorktreeDisplayName: null
})
expect(store.getState().workspaceSpaceMeasurements).toEqual([
{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }
])
})
it('removes deleted worktrees from cached analysis totals', () => {
const store = createWorkspaceSpaceTestStore()
store.setState({ workspaceSpaceAnalysis: makeAnalysis() })
@@ -1,7 +1,8 @@
import type { StateCreator } from 'zustand'
import type {
WorkspaceSpaceAnalysis,
WorkspaceSpaceScanProgress
WorkspaceSpaceScanProgress,
WorkspaceSpaceWorktreeMeasurement
} from '../../../../shared/workspace-space-types'
import type { AppState } from '../types'
@@ -12,6 +13,7 @@ export type WorkspaceSpaceSlice = {
workspaceSpaceScanProgress: WorkspaceSpaceScanProgress | null
workspaceSpaceScanError: string | null
workspaceSpaceScanning: boolean
workspaceSpaceMeasurements: WorkspaceSpaceWorktreeMeasurement[]
applyWorkspaceSpaceProgress: (progress: WorkspaceSpaceScanProgress) => void
cancelWorkspaceSpaceScan: () => Promise<boolean>
/** Stale-while-revalidate seed; true when the persisted analysis filled an empty slice. */
@@ -74,6 +76,7 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
workspaceSpaceScanProgress: null,
workspaceSpaceScanError: null,
workspaceSpaceScanning: false,
workspaceSpaceMeasurements: [],
applyWorkspaceSpaceProgress: (progress) =>
set((state) => {
if (
@@ -82,9 +85,14 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
) {
return state
}
const sameScan = state.workspaceSpaceScanProgress?.scanId === progress.scanId
const previousMeasurements = sameScan ? state.workspaceSpaceMeasurements : []
return {
workspaceSpaceScanProgress: progress,
workspaceSpaceScanning: true
workspaceSpaceScanning: true,
workspaceSpaceMeasurements: progress.completedMeasurements?.length
? [...previousMeasurements, ...progress.completedMeasurements]
: previousMeasurements
}
}),
cancelWorkspaceSpaceScan: async () => {
@@ -134,7 +142,8 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
set({
workspaceSpaceScanning: true,
workspaceSpaceScanProgress: null,
workspaceSpaceScanError: null
workspaceSpaceScanError: null,
workspaceSpaceMeasurements: []
})
// Why: the compact Resource Manager card and the full Space page share
// one manual scan result; duplicate button presses should join the same IO.
@@ -148,7 +157,8 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
set({
workspaceSpaceAnalysis: analysis,
workspaceSpaceScanning: false,
workspaceSpaceScanProgress: null
workspaceSpaceScanProgress: null,
workspaceSpaceMeasurements: []
})
return analysis
})
@@ -158,7 +168,8 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
? null
: errorMessage(error),
workspaceSpaceScanning: false,
workspaceSpaceScanProgress: null
workspaceSpaceScanProgress: null,
workspaceSpaceMeasurements: []
})
throw error
})
@@ -171,15 +182,16 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
if (worktreeIds.length > 0) {
get().recordFeatureInteraction?.('workspace-cleanup')
}
set((state) =>
state.workspaceSpaceAnalysis
? {
workspaceSpaceAnalysis: removeDeletedWorktreesFromAnalysis(
state.workspaceSpaceAnalysis,
worktreeIds
)
}
: state
)
set((state) => {
const deletedSet = new Set(worktreeIds)
return {
workspaceSpaceAnalysis: state.workspaceSpaceAnalysis
? removeDeletedWorktreesFromAnalysis(state.workspaceSpaceAnalysis, worktreeIds)
: null,
workspaceSpaceMeasurements: state.workspaceSpaceMeasurements.filter(
(measurement) => !deletedSet.has(measurement.worktreeId)
)
}
})
}
})
+7
View File
@@ -70,6 +70,11 @@ export type WorkspaceSpaceAnalyzeResult =
| { ok: true; analysis: WorkspaceSpaceAnalysis }
| { ok: false; cancelled: true }
export type WorkspaceSpaceWorktreeMeasurement = Pick<
WorkspaceSpaceWorktree,
'worktreeId' | 'status' | 'sizeBytes'
>
export type WorkspaceSpaceDirectoryScanResult = {
sizeBytes: number
skippedEntryCount: number
@@ -89,4 +94,6 @@ export type WorkspaceSpaceScanProgress = {
scannedWorktreeCount: number
currentRepoDisplayName: string | null
currentWorktreeDisplayName: string | null
/** Append-only batch; absent for older senders and progress-only updates. */
completedMeasurements?: WorkspaceSpaceWorktreeMeasurement[]
}