From 1fb444fb4d02cf460385f844f3ec04ecafca2b8a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:00:36 -0700 Subject: [PATCH] perf(workspace-cleanup): stream scan progress and size results --- .../ipc/workspace-cleanup-broad-scan.test.ts | 40 ++++ .../ipc/workspace-cleanup-progress-emitter.ts | 82 ++++++++ src/main/ipc/workspace-cleanup-scan.ts | 93 +++------ src/main/ipc/workspace-cleanup.test.ts | 4 +- src/main/ipc/workspace-space.test.ts | 57 ++++++ src/main/ipc/workspace-space.ts | 14 +- src/main/workspace-space-analysis.test.ts | 11 ++ src/main/workspace-space-analysis.ts | 14 +- ...rkspaceCleanupDialog.mount-gating.test.tsx | 154 +++++++++++++++ .../WorkspaceCleanupDialog.tsx | 97 +++++----- .../use-workspace-cleanup-dialog-lifecycle.ts | 82 ++++++++ .../use-workspace-cleanup-facet-rows.test.tsx | 182 ++++++++++++++++++ .../use-workspace-cleanup-facet-rows.ts | 69 ++++++- .../use-workspace-cleanup-scan-lifecycle.ts | 2 +- .../workspace-cleanup-browse-toolbar.tsx | 7 +- .../workspace-cleanup-candidate-list.tsx | 4 +- .../workspace-cleanup-confirm-remove.test.tsx | 75 ++++++++ .../workspace-cleanup-confirm-remove.tsx | 27 ++- .../workspace-cleanup-dialog-notices.tsx | 28 +-- .../workspace-cleanup-facets.ts | 10 +- .../workspace-cleanup-filter-bar.test.tsx | 51 +++++ .../workspace-cleanup-filter-bar.tsx | 22 ++- .../workspace-cleanup-presentation.ts | 4 +- .../workspace-cleanup-query.ts | 14 +- .../workspace-cleanup-scan-notice.ts | 10 +- src/renderer/src/i18n/locales/en.json | 14 +- .../src/store/slices/workspace-space.test.ts | 31 +++ .../src/store/slices/workspace-space.ts | 42 ++-- src/shared/workspace-space-types.ts | 7 + 29 files changed, 1050 insertions(+), 197 deletions(-) create mode 100644 src/main/ipc/workspace-cleanup-progress-emitter.ts create mode 100644 src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts create mode 100644 src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index 0f663f4c58e..4a97ecacc4e 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -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', diff --git a/src/main/ipc/workspace-cleanup-progress-emitter.ts b/src/main/ipc/workspace-cleanup-progress-emitter.ts new file mode 100644 index 00000000000..3b096b8cde2 --- /dev/null +++ b/src/main/ipc/workspace-cleanup-progress-emitter.ts @@ -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 + } +} diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index 4af4ea36eb4..8f4bc3868ea 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -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') - } - } -} diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index e46342c23af..0f4354a96c1 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -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']) ) diff --git a/src/main/ipc/workspace-space.test.ts b/src/main/ipc/workspace-space.test.ts index 2c3d373ccd0..63b118eff19 100644 --- a/src/main/ipc/workspace-space.test.ts +++ b/src/main/ipc/workspace-space.test.ts @@ -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((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 diff --git a/src/main/ipc/workspace-space.ts b/src/main/ipc/workspace-space.ts index 12d2e54c4d7..5b10b039b31 100644 --- a/src/main/ipc/workspace-space.ts +++ b/src/main/ipc/workspace-space.ts @@ -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 diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index 15baa04fc04..22011fee364 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -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 () => { diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 79304980f1f..c9b9ae1e89c 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -703,8 +703,13 @@ function reportProgress( updates: Partial, 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 diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx new file mode 100644 index 00000000000..452c08b5d4e --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx @@ -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: () =>
+ } +}) + +const initialState = useAppStore.getInitialState() +let container: HTMLDivElement +let root: Root + +function emptyScan(scannedAt: number): WorkspaceCleanupScanResult { + return { scannedAt, candidates: [], errors: [] } +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function openDialog(): Promise { + await act(async () => useAppStore.getState().openModal('workspace-cleanup')) + await flush() +} + +async function closeDialog(): Promise { + 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()) + 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((resolve) => { + resolveScan = resolve + }) + const scanWorkspaceCleanup = vi.fn(() => scanPromise) + seedStore(scanWorkspaceCleanup) + await act(async () => root.render()) + 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' }) }) + ) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index b011abd1007..0ba1678e569 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -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 +} + +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 = {} @@ -62,53 +78,26 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { [deletionPhaseByWorktreeId] ) - const open = activeModal === 'workspace-cleanup' const browse = useWorkspaceCleanupBrowseState() - const [selectedIds, setSelectedIds] = useState>(() => new Set()) const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) const [rowsScrollElement, setRowsScrollElement] = useState(null) const selectedDefaultsScanAtRef = useRef(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} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts new file mode 100644 index 00000000000..59005509259 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-dialog-lifecycle.ts @@ -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 + setSelectedIds: Dispatch>> + 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>(() => 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 + } +} diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx new file mode 100644 index 00000000000..8393c3d8237 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.test.tsx @@ -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: (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() + return { + ...actual, + countWorkspaceCleanupMeasuredRows: ( + ...args: Parameters + ) => { + counts.measured += 1 + return actual.countWorkspaceCleanupMeasuredRows(...args) + } + } +}) + +vi.mock('./workspace-cleanup-query', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + countWorkspaceCleanupFacetMatches: ( + ...args: Parameters + ) => { + counts.facetCounts += 1 + return actual.countWorkspaceCleanupFacetMatches(...args) + }, + runWorkspaceCleanupQuery: (...args: Parameters) => { + 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) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts index 8398230bf14..10a3d07b041 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-facet-rows.ts @@ -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() + 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( + () => ({ + 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( () => ({ @@ -144,6 +195,6 @@ export function useWorkspaceCleanupFacetRows({ options, reviewInfoByWorktreeId, sizeByWorktreeId, - measuredSizeCount: countWorkspaceCleanupMeasuredRows(facets) + measuredSizeCount } } diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts index d21ead7eeea..6d0b5f9f6c5 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-scan-lifecycle.ts @@ -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( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx index cc74a92fb18..0c5b9858468 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-browse-toolbar.tsx @@ -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 ? ( ({ 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 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 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. diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx new file mode 100644 index 00000000000..db6d239fdf3 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.test.tsx @@ -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: ({ + 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 }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/scroll-area', () => ({ + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
+})) + +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( + + ) + }) + + expect(listProbe).toHaveBeenCalledWith(candidates.length) + expect(container.querySelectorAll('span.truncate.text-sm.font-medium')).toHaveLength(5) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx index c284197854e..26d246a2f6d 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -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(null) const count = candidates.length const deleting = progress !== null const progressValue = progress @@ -120,15 +124,20 @@ export function WorkspaceCleanupConfirmRemove({ )}
- - {candidates.map((candidate, index) => ( - - ))} + + ( + + )} + /> diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx index 4a7b0c70946..1c41466e45b 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-dialog-notices.tsx @@ -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 (
-
- {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968', - 'Checking inactive workspaces' - )} -
+
{title}
{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.' )}
-
- {formatWorkspaceCleanupScanProgress(progress)} -
) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts index fb4dbadebad..465df3046a5 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -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 { const index = new Map() for (const entry of worktrees ?? []) { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx new file mode 100644 index 00000000000..e63b5448cd7 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.test.tsx @@ -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( + + ) + + expect( + (screen.getByRole('button', { name: 'Measuring 23/100' }) as HTMLButtonElement).disabled + ).toBe(true) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx index f01a877074d..decce5ed8b0 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-filter-bar.tsx @@ -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({ ) : null} - {sizeScan.measuredCount === 0 ? ( - ) : null} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts index bfcc64cc0fa..75ab9fd7bdd 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-presentation.ts @@ -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 { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts index 0fa43b8a4ea..6a722d15ab0 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-query.ts @@ -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( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts index 869fb3d5de2..f548dc8a475 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-scan-notice.ts @@ -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( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d464ebe61ed..2cbc9592f8b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" } } }, diff --git a/src/renderer/src/store/slices/workspace-space.test.ts b/src/renderer/src/store/slices/workspace-space.test.ts index 1da5d8e2660..53e0c3ad798 100644 --- a/src/renderer/src/store/slices/workspace-space.test.ts +++ b/src/renderer/src/store/slices/workspace-space.test.ts @@ -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() }) diff --git a/src/renderer/src/store/slices/workspace-space.ts b/src/renderer/src/store/slices/workspace-space.ts index dbdd99f4861..c11759f65df 100644 --- a/src/renderer/src/store/slices/workspace-space.ts +++ b/src/renderer/src/store/slices/workspace-space.ts @@ -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 /** Stale-while-revalidate seed; true when the persisted analysis filled an empty slice. */ @@ -74,6 +76,7 @@ export const createWorkspaceSpaceSlice: StateCreator set((state) => { if ( @@ -82,9 +85,14 @@ export const createWorkspaceSpaceSlice: StateCreator { @@ -134,7 +142,8 @@ export const createWorkspaceSpaceSlice: StateCreator 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) + ) + } + }) } }) diff --git a/src/shared/workspace-space-types.ts b/src/shared/workspace-space-types.ts index fa77086ad4b..eb65e965403 100644 --- a/src/shared/workspace-space-types.ts +++ b/src/shared/workspace-space-types.ts @@ -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[] }