diff --git a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts index 2152e5904fe..79984b55721 100644 --- a/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts +++ b/src/main/runtime/rpc/methods/workspace-cleanup-ui-schema.ts @@ -4,14 +4,12 @@ import { type WorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state' -const WorkspaceCleanupDismissal = z - .object({ - worktreeId: z.string(), - dismissedAt: z.number().finite(), - fingerprint: z.string(), - classifierVersion: z.number().finite() - }) - .strict() +const WorkspaceCleanupDismissal = z.object({ + worktreeId: z.string(), + dismissedAt: z.number().finite(), + fingerprint: z.string(), + classifierVersion: z.number().finite() +}) /** * Deliberately unvalidated shape, then normalized: the filter groups must NOT be @@ -25,9 +23,7 @@ const WorkspaceCleanupBrowse = z .custom() .transform((value) => normalizeWorkspaceCleanupBrowseState(value)) -export const WorkspaceCleanup = z - .object({ - dismissals: z.record(z.string(), WorkspaceCleanupDismissal), - browse: WorkspaceCleanupBrowse.optional() - }) - .strict() +export const WorkspaceCleanup = z.object({ + dismissals: z.record(z.string(), WorkspaceCleanupDismissal), + browse: WorkspaceCleanupBrowse.optional() +}) diff --git a/src/main/workspace-cleanup-scan-snapshot.test.ts b/src/main/workspace-cleanup-scan-snapshot.test.ts index 4a9f051a928..2398fcef7ab 100644 --- a/src/main/workspace-cleanup-scan-snapshot.test.ts +++ b/src/main/workspace-cleanup-scan-snapshot.test.ts @@ -84,6 +84,26 @@ describe('workspace cleanup scan snapshot', () => { await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(result) }) + it('does not let an older broad scan overwrite a newer snapshot', async () => { + const newer = { + scannedAt: NOW + 1, + candidates: [makeCandidate({ displayName: 'Newer' })], + errors: [] + } + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + newer + ) + await persistWorkspaceCleanupScanResult( + userDataDirHolder.dir, + { includeAllWorkspaces: true }, + makeBroadResult([makeCandidate({ displayName: 'Older' })]) + ) + + await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toEqual(newer) + }) + it('returns null when no snapshot has been persisted', async () => { await expect(readWorkspaceCleanupScanSnapshot(userDataDirHolder.dir)).resolves.toBeNull() }) diff --git a/src/main/workspace-cleanup-scan-snapshot.ts b/src/main/workspace-cleanup-scan-snapshot.ts index b415cfa7280..a0cd91e3bfd 100644 --- a/src/main/workspace-cleanup-scan-snapshot.ts +++ b/src/main/workspace-cleanup-scan-snapshot.ts @@ -191,6 +191,10 @@ export async function persistWorkspaceCleanupScanResult( const filteredResult = excludeRowsPrunedDuringScan(file, result) const broad = !args.worktreeId && args.includeAllWorkspaces === true if (broad) { + const existing = await readWorkspaceCleanupScanSnapshot(snapshotDirectory) + if (existing && existing.scannedAt > filteredResult.scannedAt) { + return + } await writeSnapshot(file, filteredResult) clearSupersededPrunes(file, result, true) return diff --git a/src/main/workspace-space-analysis-snapshot.test.ts b/src/main/workspace-space-analysis-snapshot.test.ts index 2ccad601781..1a5f7e30902 100644 --- a/src/main/workspace-space-analysis-snapshot.test.ts +++ b/src/main/workspace-space-analysis-snapshot.test.ts @@ -147,6 +147,23 @@ describe('workspace space analysis snapshot', () => { await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toBeNull() }) + it('loads legacy rows without an execution host id', async () => { + const { executionHostId: _executionHostId, ...legacyRow } = makeWorktreeRow() + const analysis = makeAnalysis([makeWorktreeRow()]) + await writeFile( + join(userDataDirHolder.dir, SNAPSHOT_FILE), + JSON.stringify({ + version: 2, + analysis: { ...analysis, worktrees: [legacyRow] } + }), + 'utf-8' + ) + + await expect(readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir)).resolves.toMatchObject({ + worktrees: [expect.objectContaining({ worktreeId: legacyRow.worktreeId })] + }) + }) + it('prunes a removed worktree row and rebalances totals', async () => { const removed = makeWorktreeRow({ sizeBytes: 3000, reclaimableBytes: 3000 }) const kept = makeWorktreeRow({ @@ -256,4 +273,29 @@ describe('workspace space analysis snapshot', () => { }) ]) }) + + it('prunes every host-colliding row when the host is unknown', async () => { + const local = makeWorktreeRow({ sizeBytes: 1000, reclaimableBytes: 1000 }) + const remote = makeWorktreeRow({ + executionHostId: 'ssh:ssh-1', + isRemote: true, + sizeBytes: 3000, + reclaimableBytes: 3000 + }) + await persistWorkspaceSpaceAnalysisSnapshot( + userDataDirHolder.dir, + makeAnalysis([local, remote]) + ) + + await pruneWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir, local.worktreeId) + + const cached = await readWorkspaceSpaceAnalysisSnapshot(userDataDirHolder.dir) + expect(cached?.worktrees).toEqual([]) + expect(cached).toMatchObject({ + worktreeCount: 0, + scannedWorktreeCount: 0, + totalSizeBytes: 0, + reclaimableBytes: 0 + }) + }) }) diff --git a/src/main/workspace-space-analysis-snapshot.ts b/src/main/workspace-space-analysis-snapshot.ts index ebdb461959e..e1210865079 100644 --- a/src/main/workspace-space-analysis-snapshot.ts +++ b/src/main/workspace-space-analysis-snapshot.ts @@ -37,7 +37,7 @@ function isPersistableWorktreeRow(value: unknown): value is WorkspaceSpaceWorktr return ( typeof value.worktreeId === 'string' && typeof value.repoId === 'string' && - typeof value.executionHostId === 'string' && + (value.executionHostId === undefined || typeof value.executionHostId === 'string') && typeof value.status === 'string' && typeof value.sizeBytes === 'number' && typeof value.reclaimableBytes === 'number' && @@ -212,15 +212,18 @@ export async function pruneWorkspaceSpaceAnalysisSnapshot( try { await withSidecarSnapshotQueue(file, async () => { const existing = await readWorkspaceSpaceAnalysisSnapshot(snapshotDirectory) - const removed = existing?.worktrees.find( + const removed = existing?.worktrees.filter( (row) => row.worktreeId === worktreeId && (executionHostId === undefined || row.executionHostId === executionHostId) ) - if (!existing || !removed) { + if (!existing || !removed || removed.length === 0) { return } - await writeSnapshot(file, withoutWorktreeRow(existing, removed)) + await writeSnapshot( + file, + removed.reduce((analysis, row) => withoutWorktreeRow(analysis, row), existing) + ) }) } catch (error) { console.warn('[workspace-space] failed to prune analysis snapshot:', error) diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 4353a6fb81a..ea91912f33b 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -969,8 +969,24 @@ function BreakdownList({ ) } - const maxChildSize = getLargestWorkspaceSpaceItemSize(worktree.topLevelItems) + const maxChildSize = Math.max( + getLargestWorkspaceSpaceItemSize(worktree.topLevelItems), + worktree.omittedTopLevelSizeBytes + ) const topLevelItemCount = worktree.topLevelItems.length + worktree.omittedTopLevelItemCount + const omittedItem: WorkspaceSpaceItem | null = + worktree.omittedTopLevelItemCount > 0 + ? { + name: translate( + 'components.status.bar.workspaceSpace.otherTopLevelItems', + 'Other top-level items ({{value0}})', + { value0: worktree.omittedTopLevelItemCount } + ), + path: '', + kind: 'other', + sizeBytes: worktree.omittedTopLevelSizeBytes + } + : null return (
@@ -1007,7 +1023,7 @@ function BreakdownList({ )}
- ) : worktree.topLevelItems.length === 0 ? ( + ) : topLevelItemCount === 0 ? (
{translate( 'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079', @@ -1020,6 +1036,7 @@ function BreakdownList({ {worktree.topLevelItems.slice(0, 12).map((item) => ( ))} + {omittedItem ? : null}
)} 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 index 452c08b5d4e..076e77f35e2 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.mount-gating.test.tsx @@ -17,6 +17,7 @@ vi.mock('./use-workspace-cleanup-facet-rows', () => ({ return { rows: [], selectableWorktreeIds: [], + facetMatchedWorktreeIds: new Set(), matchedCount: 0, totalCount: 0, facetCounts: { @@ -34,7 +35,8 @@ vi.mock('./use-workspace-cleanup-facet-rows', () => ({ options: { workspaceStatuses: [], hostIds: [], repos: [], reviewProviders: [] }, reviewInfoByWorktreeId: new Map(), sizeByWorktreeId: new Map(), - measuredSizeCount: 0 + measuredSizeCount: 0, + unmeasuredSizeCount: 0 } } })) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx index f4b9e0517d1..204b28317f1 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.stale-while-revalidate.test.tsx @@ -20,7 +20,7 @@ const holders = vi.hoisted(() => ({ }, infoToasts: [] as string[], rig: null as null | { - resolvers: ((result: { scannedAt: number; candidates: never[]; errors: never[] }) => void)[] + resolvers: ((result: WorkspaceCleanupScanResult) => void)[] } })) @@ -127,7 +127,7 @@ function installApi(cachedScan: WorkspaceCleanupScanResult | null): ScanRig { }, ui: { set: vi.fn().mockResolvedValue(undefined) } } - holders.rig = rig as unknown as typeof holders.rig + holders.rig = rig return rig } @@ -155,7 +155,7 @@ async function openDialog(): Promise { } function rowNames(): string[] { - return [...(container?.querySelectorAll('span.truncate.text-sm.font-medium') ?? [])].map( + return [...(container?.querySelectorAll('[data-workspace-cleanup-row-name]') ?? [])].map( (node) => node.textContent ?? '' ) } @@ -288,9 +288,36 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => { // Rows stay selectable during the stream. const checkbox = rowCheckbox('alpha') expect(checkbox).not.toBeNull() + const before = checkbox?.getAttribute('aria-checked') await act(async () => { checkbox?.click() }) + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).not.toBe(before) + }) + + it('does not clear selection while free-text search hides the row', async () => { + installApi(cachedFleet()) + await renderDialog() + await openDialog() + + if (rowCheckbox('alpha')?.getAttribute('aria-checked') !== 'true') { + await act(async () => rowCheckbox('alpha')?.click()) + } + expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') + const search = container?.querySelector('[aria-label="Search workspaces"]') + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + await act(async () => { + setter?.call(search, 'beta') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + await flush() + expect(rowNames()).toEqual(['beta']) + + await act(async () => { + setter?.call(search, '') + search?.dispatchEvent(new Event('input', { bubbles: true })) + }) + await flush() expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true') }) diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 0ba1678e569..9f8a5cc38ee 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -56,6 +56,7 @@ function WorkspaceCleanupDialogContent({ const scan = useAppStore((s) => s.workspaceCleanupScan) const scanProgress = useAppStore((s) => s.workspaceCleanupProgress) const error = useAppStore((s) => s.workspaceCleanupError) + const spaceError = useAppStore((s) => s.workspaceSpaceScanError) const repos = useAppStore((s) => s.repos) const markCandidateViewed = useAppStore((s) => s.markWorkspaceCleanupCandidateViewed) const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates) @@ -123,7 +124,6 @@ function WorkspaceCleanupDialogContent({ streaming: loading, sort: browse.sort }) - const rowIds = useMemo(() => new Set(rows.map((row) => row.worktreeId)), [rows]) const candidateIds = useMemo( () => new Set(candidates.map((candidate) => candidate.worktreeId)), [candidates] @@ -139,8 +139,19 @@ function WorkspaceCleanupDialogContent({ ) const initialLoading = loading && candidates.length === 0 const runSpaceScan = useCallback(() => { - void refreshWorkspaceSpace().catch(() => undefined) - }, [refreshWorkspaceSpace]) + void refreshWorkspaceSpace().catch((scanError: unknown) => { + if (!mountedRef.current) { + return + } + toast.error( + translate( + 'components.workspace.cleanup.browse.measureSizesFailed', + 'Could not measure workspace sizes' + ), + { description: scanError instanceof Error ? scanError.message : String(scanError) } + ) + }) + }, [mountedRef, refreshWorkspaceSpace]) const selectedCandidates = useMemo(() => { const byId = new Map(rows.map((row) => [row.worktreeId, row.candidate])) @@ -173,7 +184,9 @@ function WorkspaceCleanupDialogContent({ const pruneSelectionToVisibleRows = useEffectEvent(() => { setSelectedIds((current) => { const next = new Set( - [...current].filter((id) => rowIds.has(id) && !deletingWorktreeIds.has(id)) + [...current].filter( + (id) => facetRows.facetMatchedWorktreeIds.has(id) && !deletingWorktreeIds.has(id) + ) ) return next.size === current.size ? current : next }) @@ -187,7 +200,7 @@ function WorkspaceCleanupDialogContent({ // currently review after a filter change — and only then; a // streaming refresh re-classifying a row must not silently deselect it. pruneSelectionToVisibleRows() - }, [browse.filters, open, removal.confirming]) + }, [facetRows.facetMatchedWorktreeIds, open, removal.confirming]) const pruneVanishedSelections = useEffectEvent(() => { const kept = [...selectedIds].filter( @@ -329,6 +342,9 @@ function WorkspaceCleanupDialogContent({ {initialLoading ? : null} {error ? : null} + {!error && spaceError ? ( + + ) : null} {!error && scanNoticeMessage ? ( ) : null} 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 index 59005509259..194ed95240b 100644 --- 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 @@ -52,6 +52,7 @@ export function useWorkspaceCleanupDialogLifecycle(): WorkspaceCleanupDialogLife const { startWorkspaceCleanupScan } = useWorkspaceCleanupScanLifecycle({ open, loading, + removalInFlight: removal.removalInFlight, removalInFlightRef, resetRowFailures, onFreshOpen 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 10a3d07b041..e27bc792f43 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 @@ -18,6 +18,7 @@ import { } from './workspace-cleanup-facets' import { countWorkspaceCleanupFacetMatches, + filterWorkspaceCleanupFacets, runWorkspaceCleanupQuery } from './workspace-cleanup-query' import { @@ -32,6 +33,7 @@ import type { export type WorkspaceCleanupFacetRows = { rows: WorkspaceCleanupFacets[] selectableWorktreeIds: string[] + facetMatchedWorktreeIds: ReadonlySet matchedCount: number totalCount: number facetCounts: WorkspaceCleanupFacetCounts @@ -40,6 +42,7 @@ export type WorkspaceCleanupFacetRows = { sizeByWorktreeId: ReadonlyMap /** 0 means the workspace-space scan has never produced a usable size. */ measuredSizeCount: number + unmeasuredSizeCount: number } /** @@ -163,7 +166,13 @@ export function useWorkspaceCleanupFacetRows({ () => countWorkspaceCleanupFacetMatches(facets, facetFilters, now), [facets, facetFilters, now] ) + const facetMatchedWorktreeIds = useMemo( + () => + new Set(filterWorkspaceCleanupFacets(facets, facetFilters, now).map((row) => row.worktreeId)), + [facets, facetFilters, now] + ) const measuredSizeCount = useMemo(() => countWorkspaceCleanupMeasuredRows(facets), [facets]) + const unmeasuredSizeCount = facets.length - measuredSizeCount const options = useMemo( () => ({ @@ -189,12 +198,14 @@ export function useWorkspaceCleanupFacetRows({ return { rows: result.rows, selectableWorktreeIds: result.selectableWorktreeIds, + facetMatchedWorktreeIds, matchedCount: result.matchedCount, totalCount: result.totalCount, facetCounts, options, reviewInfoByWorktreeId, sizeByWorktreeId, - measuredSizeCount + measuredSizeCount, + unmeasuredSizeCount } } diff --git a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts index 6e016fc9af2..8e8bb328af2 100644 --- a/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/use-workspace-cleanup-removal.ts @@ -136,61 +136,67 @@ export function useWorkspaceCleanupRemoval({ const removableWorktreeIds = removableCandidates.map((candidate) => candidate.worktreeId) setRowFailures({}) markWorktreesQueuedForDeletion(removableWorktreeIds) - startWorkspaceCleanupBackgroundRemoval({ - candidates: removableCandidates, - removeCandidates, - onProgress: (progress) => { - if (mountedRef.current) { - setRemovalProgress(progress) - } - }, - onRowFailed: (failure) => { - clearQueuedDeleteState(failure.worktreeId) - }, - onResult: (result) => { - const nextFailures: Record = {} - for (const failure of result.failures) { - nextFailures[failure.worktreeId] = failure.message - clearQueuedDeleteState(failure.worktreeId) - } - if (mountedRef.current) { - setRowFailures(nextFailures) - onDeselect(result.removedIds) - settle() - } - removalInFlightRef.current = false - }, - onLateResult: (result) => { - for (const failure of result.failures) { - // Why: a late failure can come from a hung preflight whose row never - // reached 'deleting'; clear its queued overlay like every other path. - clearQueuedDeleteState(failure.worktreeId) - } - if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) { - return - } - setRowFailures((current) => { - const next = { ...current } - for (const id of result.removedIds) { - delete next[id] - } - for (const failure of result.failures) { - next[failure.worktreeId] = failure.message - } - return next - }) - onDeselect(result.removedIds) - }, - onError: () => { - for (const worktreeId of removableWorktreeIds) { - clearWorktreeDeleteState(worktreeId) - } - if (mountedRef.current) { - settle() - } - removalInFlightRef.current = false + const handleRemovalError = (): void => { + for (const worktreeId of removableWorktreeIds) { + clearWorktreeDeleteState(worktreeId) } - }) + if (mountedRef.current) { + settle() + } + removalInFlightRef.current = false + } + try { + startWorkspaceCleanupBackgroundRemoval({ + candidates: removableCandidates, + removeCandidates, + onProgress: (progress) => { + if (mountedRef.current) { + setRemovalProgress(progress) + } + }, + onRowFailed: (failure) => { + clearQueuedDeleteState(failure.worktreeId) + }, + onResult: (result) => { + const nextFailures: Record = {} + for (const failure of result.failures) { + nextFailures[failure.worktreeId] = failure.message + // Why: defensively covers failures that never reached onRowFailed. + clearQueuedDeleteState(failure.worktreeId) + } + if (mountedRef.current) { + setRowFailures(nextFailures) + onDeselect(result.removedIds) + settle() + } + removalInFlightRef.current = false + }, + onLateResult: (result) => { + for (const failure of result.failures) { + // Why: a late failure can come from a hung preflight whose row never + // reached 'deleting'; clear its queued overlay like every other path. + clearQueuedDeleteState(failure.worktreeId) + } + if (!mountedRef.current || removalBatchIdRef.current !== removalBatchId) { + return + } + setRowFailures((current) => { + const next = { ...current } + for (const id of result.removedIds) { + delete next[id] + } + for (const failure of result.failures) { + next[failure.worktreeId] = failure.message + } + return next + }) + onDeselect(result.removedIds) + }, + onError: handleRemovalError + }) + } catch { + handleRemovalError() + } }, [ clearQueuedDeleteState, clearWorktreeDeleteState, 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 6d0b5f9f6c5..c29a0fabe47 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 @@ -17,12 +17,14 @@ export type WorkspaceCleanupScanLifecycle = { export function useWorkspaceCleanupScanLifecycle({ open, loading, + removalInFlight, removalInFlightRef, resetRowFailures, onFreshOpen }: { open: boolean loading: boolean + removalInFlight: boolean removalInFlightRef: { current: boolean } resetRowFailures: () => void onFreshOpen: () => void @@ -113,7 +115,10 @@ export function useWorkspaceCleanupScanLifecycle({ // Why: reopening mid-batch keeps the deletion progress view; a broad scan // started here would be discarded by the removal's scan invalidation, so // skip it while a removal batch is running. - if (autoScanAttemptedForOpenRef.current || removalInFlightRef.current) { + if (removalInFlight || removalInFlightRef.current) { + return + } + if (autoScanAttemptedForOpenRef.current) { return } autoScanAttemptedForOpenRef.current = true @@ -123,7 +128,7 @@ export function useWorkspaceCleanupScanLifecycle({ return } hydrateThenScan() - }, [hydrateThenScan, loading, onFreshOpen, open, removalInFlightRef]) + }, [hydrateThenScan, loading, onFreshOpen, open, removalInFlight, removalInFlightRef]) return { startWorkspaceCleanupScan } } 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 0c5b9858468..9bb2f6901a8 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 @@ -50,6 +50,7 @@ export function WorkspaceCleanupBrowseToolbar({ hasActiveFilters={activeFilters} sizeScan={{ measuredCount: facetRows.measuredSizeCount, + unmeasuredCount: facetRows.unmeasuredSizeCount, scanning: spaceScanning, scannedCount: spaceProgress?.scannedWorktreeCount ?? 0, totalCount: spaceProgress?.totalWorktreeCount ?? 0, diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts index a1e6d2dd0e8..7ed376b10b6 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts @@ -41,6 +41,18 @@ describe('workspace cleanup candidate row data', () => { ).toBe(true) }) + it('keeps unpushed risk visible for archived rows', () => { + expect( + getCandidateFactStatus( + makeCandidate({ + tier: 'review', + reasons: ['archived'], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toMatchObject({ label: 'Unpushed commits' }) + }) + it('suppresses the git metadata chip when the status pill already names git risk', () => { expect( shouldShowGitMetadataChip( diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts index 3c9952c3c74..42a7d0c58a5 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -36,15 +36,6 @@ export function getCandidateFactStatus(candidate: WorkspaceCleanupCandidate): { tone: 'neutral' } } - if (candidate.reasons.includes('archived')) { - return { - label: translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', - 'Archived' - ), - tone: 'neutral' - } - } if (candidate.blockers.length > 0) { return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' } } @@ -66,6 +57,15 @@ export function getCandidateFactStatus(candidate: WorkspaceCleanupCandidate): { tone: 'review' } } + if (candidate.reasons.includes('archived')) { + return { + label: translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus', + 'Archived' + ), + tone: 'neutral' + } + } return null } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index 68ec1c4cafa..f8f68b03a9c 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -146,7 +146,9 @@ export const CandidateRow = React.memo(function CandidateRow({
- {candidate.displayName} + + {candidate.displayName} + {deletionPhase ? ( {deletionPhase === 'queued' @@ -192,6 +194,7 @@ export const CandidateRow = React.memo(function CandidateRow({ /> {gitEvidencePending ? ( - {selectedCount}{' '} - {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ac5ba84cc1', - 'selected' - )} + {translate('components.workspace.cleanup.browse.selectedCount', '{{value0}} selected', { + value0: selectedCount + })}