fix(workspace-cleanup): address review feedback

This commit is contained in:
Brennan Benson
2026-08-12 18:32:51 -07:00
parent b0ca0c3c40
commit e0be03b3c2
36 changed files with 493 additions and 867 deletions
@@ -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<WorkspaceCleanupBrowseState>()
.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()
})
@@ -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()
})
@@ -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
@@ -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
})
})
})
@@ -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)
@@ -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 (
<div className="min-h-72 rounded-lg border border-border/70 bg-background/35">
<div className="border-b border-border/60 px-4 py-3">
@@ -1007,7 +1023,7 @@ function BreakdownList({
)}
</span>
</div>
) : worktree.topLevelItems.length === 0 ? (
) : topLevelItemCount === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.16988df079',
@@ -1020,6 +1036,7 @@ function BreakdownList({
{worktree.topLevelItems.slice(0, 12).map((item) => (
<BreakdownRow key={`${item.path}:${item.name}`} item={item} maxSize={maxChildSize} />
))}
{omittedItem ? <BreakdownRow item={omittedItem} maxSize={maxChildSize} /> : null}
</div>
</div>
)}
@@ -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
}
}
}))
@@ -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<void> {
}
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<HTMLInputElement>('[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')
})
@@ -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 ? <WorkspaceCleanupInitialScanBanner progress={scanProgress} /> : null}
{error ? <WorkspaceCleanupNotice tone="destructive" message={error} /> : null}
{!error && spaceError ? (
<WorkspaceCleanupNotice tone="destructive" message={spaceError} />
) : null}
{!error && scanNoticeMessage ? (
<WorkspaceCleanupNotice message={scanNoticeMessage} />
) : null}
@@ -52,6 +52,7 @@ export function useWorkspaceCleanupDialogLifecycle(): WorkspaceCleanupDialogLife
const { startWorkspaceCleanupScan } = useWorkspaceCleanupScanLifecycle({
open,
loading,
removalInFlight: removal.removalInFlight,
removalInFlightRef,
resetRowFailures,
onFreshOpen
@@ -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<string>
matchedCount: number
totalCount: number
facetCounts: WorkspaceCleanupFacetCounts
@@ -40,6 +42,7 @@ export type WorkspaceCleanupFacetRows = {
sizeByWorktreeId: ReadonlyMap<string, number>
/** 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<WorkspaceCleanupFacetOptions>(
() => ({
@@ -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
}
}
@@ -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<string, string> = {}
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<string, string> = {}
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,
@@ -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 }
}
@@ -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,
@@ -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(
@@ -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
}
@@ -146,7 +146,9 @@ export const CandidateRow = React.memo(function CandidateRow({
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="min-w-0 truncate text-sm font-medium">{candidate.displayName}</span>
<span data-workspace-cleanup-row-name className="min-w-0 truncate text-sm font-medium">
{candidate.displayName}
</span>
{deletionPhase ? (
<StatusPill tone="destructive">
{deletionPhase === 'queued'
@@ -192,6 +194,7 @@ export const CandidateRow = React.memo(function CandidateRow({
/>
{gitEvidencePending ? (
<span
role="status"
className="inline-flex h-5 shrink-0 items-center gap-1 rounded-full border border-border bg-background px-1.5 text-[11px] font-medium text-muted-foreground"
aria-label={translate(
'components.workspace.cleanup.browse.checkingGitRow',
@@ -47,11 +47,9 @@ export function WorkspaceCleanupDialogHeader({
progress={scanProgress}
/>
<span className="text-sm font-medium text-foreground">
{selectedCount}{' '}
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ac5ba84cc1',
'selected'
)}
{translate('components.workspace.cleanup.browse.selectedCount', '{{value0}} selected', {
value0: selectedCount
})}
</span>
<Button
variant="destructive"
@@ -0,0 +1,26 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { WorkspaceCleanupInitialScanBanner } from './workspace-cleanup-dialog-notices'
afterEach(cleanup)
describe('WorkspaceCleanupInitialScanBanner', () => {
it('puts determinate progress in the title with matching typography', () => {
render(
<WorkspaceCleanupInitialScanBanner
progress={{
scanId: 'scan-1',
scannedAt: 1,
scannedWorktreeCount: 23,
totalWorktreeCount: 3048,
candidates: [],
errors: []
}}
/>
)
expect(screen.getByText('Scanning workspaces (23/3048)')).toBeTruthy()
expect(screen.queryByText('Checked workspaces so far: 23')).toBeNull()
})
})
@@ -1,175 +0,0 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup'
import { useAppStore } from '@/store'
import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup'
import {
useWorkspaceCleanupDialogSession,
type WorkspaceCleanupDialogSession
} from './workspace-cleanup-dialog-session'
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
const toastMocks = vi.hoisted(() => ({
error: vi.fn(),
info: vi.fn(),
success: vi.fn()
}))
vi.mock('sonner', () => ({ toast: toastMocks }))
const initialAppState = useAppStore.getInitialState()
let container: HTMLDivElement
let root: Root
let currentSession: WorkspaceCleanupDialogSession | null
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve
})
return { promise, resolve }
}
function Probe(): null {
currentSession = useWorkspaceCleanupDialogSession()
return null
}
function session(): WorkspaceCleanupDialogSession {
if (!currentSession) {
throw new Error('Workspace cleanup session probe is not mounted')
}
return currentSession
}
async function renderProbe(): Promise<void> {
await act(async () => root.render(<Probe />))
}
describe('useWorkspaceCleanupDialogSession', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
vi.clearAllMocks()
useAppStore.setState(initialAppState, true)
useAppStore.setState({
activeModal: 'workspace-cleanup',
workspaceCleanupLoading: false,
deleteStateByWorktreeId: {}
})
currentSession = null
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => root.unmount())
container.remove()
currentSession = null
useAppStore.setState(initialAppState, true)
})
it('offers to reopen when a scan finishes after the dialog closes', async () => {
const candidate = makeCandidate()
const scan = deferred<WorkspaceCleanupScanResult>()
const scanWorkspaceCleanup = vi.fn(() => scan.promise)
useAppStore.setState({ scanWorkspaceCleanup })
await renderProbe()
expect(scanWorkspaceCleanup).toHaveBeenCalledOnce()
await act(async () => useAppStore.getState().closeModal())
expect(session().open).toBe(false)
await act(async () => {
scan.resolve({ scannedAt: 42, candidates: [candidate], errors: [] })
await scan.promise
})
expect(toastMocks.success).toHaveBeenCalledWith(
'Inactive workspace scan ready',
expect.objectContaining({
description: '1 inactive workspace found, with 1 cleanup suggestion.',
action: expect.objectContaining({ label: 'Review' })
})
)
const options = toastMocks.success.mock.lastCall?.[1] as {
action?: { onClick: () => void }
}
await act(async () => options.action?.onClick())
expect(useAppStore.getState().activeModal).toBe('workspace-cleanup')
expect(session().open).toBe(true)
})
it('keeps a deferred removal alive and visible across close and reopen', async () => {
const candidate = makeCandidate()
const removal = deferred<WorkspaceCleanupRemoveResult>()
const removeWorkspaceCleanupCandidates = vi.fn(() => removal.promise)
const scanWorkspaceCleanup = vi.fn().mockResolvedValue({
scannedAt: 41,
candidates: [candidate],
errors: []
} satisfies WorkspaceCleanupScanResult)
useAppStore.setState({ removeWorkspaceCleanupCandidates, scanWorkspaceCleanup })
await renderProbe()
await act(async () => session().openConfirmRemove([candidate]))
await act(async () => session().confirmRemove())
expect(removeWorkspaceCleanupCandidates).toHaveBeenCalledWith([candidate.worktreeId], {
approvedCandidates: [candidate]
})
expect(session()).toMatchObject({
open: true,
confirming: true,
removalInFlight: true,
removalProgress: {
totalCount: 1,
processedCount: 0,
removedCount: 0,
failedCount: 0
}
})
await act(async () => session().close())
expect(session()).toMatchObject({
open: false,
confirming: true,
removalInFlight: true
})
expect(session().removalProgress?.processedCount).toBe(0)
await act(async () => useAppStore.getState().openModal('workspace-cleanup'))
expect(session()).toMatchObject({
open: true,
confirming: true,
removalInFlight: true
})
expect(session().removalProgress?.processedCount).toBe(0)
expect(scanWorkspaceCleanup).toHaveBeenCalledOnce()
await act(async () => {
removal.resolve({ removedIds: [candidate.worktreeId], failures: [] })
await removal.promise
await Promise.resolve()
})
expect(session()).toMatchObject({
open: true,
confirming: false,
confirmCandidates: [],
removalInFlight: false,
removalProgress: null
})
})
})
@@ -1,205 +0,0 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction
} from 'react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import {
useWorkspaceCleanupRemovalSession,
type WorkspaceCleanupRemovalSession
} from './workspace-cleanup-removal-session'
import { useWorkspaceCleanupScanSession } from './workspace-cleanup-scan-session'
import type {
WorkspaceCleanupFilters,
WorkspaceCleanupSortDirection,
WorkspaceCleanupSortKey
} from './workspace-cleanup-presentation'
import type { WorkspaceCleanupView } from './workspace-cleanup-view-selection'
export const DEFAULT_WORKSPACE_CLEANUP_FILTERS: WorkspaceCleanupFilters = {
query: '',
time: 'all',
review: 'all',
git: 'all',
context: 'all'
}
type WorkspaceCleanupStateSetter<T> = Dispatch<SetStateAction<T>>
type PublicRemovalSession = Omit<
WorkspaceCleanupRemovalSession,
'clearRowFailures' | 'resetForOpen'
>
export type WorkspaceCleanupDialogSession = PublicRemovalSession & {
open: boolean
loading: boolean
selectedIds: Set<string>
setSelectedIds: WorkspaceCleanupStateSetter<Set<string>>
expandedRowIds: Set<string>
setExpandedRowIds: WorkspaceCleanupStateSetter<Set<string>>
activeView: WorkspaceCleanupView
setActiveView: WorkspaceCleanupStateSetter<WorkspaceCleanupView>
repoSelection: ReadonlySet<string>
setRepoSelection: WorkspaceCleanupStateSetter<ReadonlySet<string>>
filters: WorkspaceCleanupFilters
setFilters: WorkspaceCleanupStateSetter<WorkspaceCleanupFilters>
sortKey: WorkspaceCleanupSortKey
setSortKey: WorkspaceCleanupStateSetter<WorkspaceCleanupSortKey>
sortDirection: WorkspaceCleanupSortDirection
setSortDirection: WorkspaceCleanupStateSetter<WorkspaceCleanupSortDirection>
selectedDefaultsScanAtRef: RefObject<number | null>
close: () => void
markCandidateViewed: (candidate: WorkspaceCleanupCandidate) => void
restoreDismissals: () => void
startScan: (options?: { notifyWhenReady?: boolean }) => void
ignoreCandidate: (candidate: WorkspaceCleanupCandidate) => void
}
export function useWorkspaceCleanupDialogSession(): WorkspaceCleanupDialogSession {
const {
open,
loading,
openModal,
closeModal,
scanWorkspaceCleanup,
markCandidateViewed,
dismissCandidates,
resetDismissals,
removeCandidates,
markWorktreesQueuedForDeletion,
clearWorktreeDeleteState
} = useAppStore(
useShallow((state) => ({
open: state.activeModal === 'workspace-cleanup',
loading: state.workspaceCleanupLoading,
openModal: state.openModal,
closeModal: state.closeModal,
scanWorkspaceCleanup: state.scanWorkspaceCleanup,
markCandidateViewed: state.markWorkspaceCleanupCandidateViewed,
dismissCandidates: state.dismissWorkspaceCleanupCandidates,
resetDismissals: state.resetWorkspaceCleanupDismissals,
removeCandidates: state.removeWorkspaceCleanupCandidates,
markWorktreesQueuedForDeletion: state.markWorktreesQueuedForDeletion,
clearWorktreeDeleteState: state.clearWorktreeDeleteState
}))
)
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
const [expandedRowIds, setExpandedRowIds] = useState<Set<string>>(() => new Set())
const [activeView, setActiveView] = useState<WorkspaceCleanupView>('ready')
const [repoSelection, setRepoSelection] = useState<ReadonlySet<string>>(() => new Set())
const [filters, setFilters] = useState<WorkspaceCleanupFilters>(DEFAULT_WORKSPACE_CLEANUP_FILTERS)
const [sortKey, setSortKey] = useState<WorkspaceCleanupSortKey>('activity')
const [sortDirection, setSortDirection] = useState<WorkspaceCleanupSortDirection>('asc')
const selectedDefaultsScanAtRef = useRef<number | null>(null)
const autoScanAttemptedForOpenRef = useRef(false)
const wasOpenRef = useRef(false)
const mountedRef = useMountedRef()
const removal = useWorkspaceCleanupRemovalSession({
mountedRef,
setSelectedIds,
closeModal,
removeCandidates,
markWorktreesQueuedForDeletion,
clearWorktreeDeleteState
})
const { clearRowFailures, resetForOpen, ...publicRemoval } = removal
const startScan = useWorkspaceCleanupScanSession({
open,
mountedRef,
openModal,
scanWorkspaceCleanup,
clearRowFailures
})
useEffect(() => {
if (!open) {
wasOpenRef.current = false
autoScanAttemptedForOpenRef.current = false
return
}
if (!wasOpenRef.current) {
wasOpenRef.current = true
autoScanAttemptedForOpenRef.current = false
if (!removal.removalInFlightRef.current) {
setActiveView('ready')
setFilters(DEFAULT_WORKSPACE_CLEANUP_FILTERS)
setSortKey('activity')
setSortDirection('asc')
resetForOpen()
}
}
// Why: reopening mid-batch must preserve progress and avoid a scan the removal would invalidate.
if (!loading && !autoScanAttemptedForOpenRef.current && !removal.removalInFlightRef.current) {
autoScanAttemptedForOpenRef.current = true
startScan({ notifyWhenReady: true })
}
}, [loading, open, removal.removalInFlightRef, resetForOpen, startScan])
const ignoreCandidate = useCallback(
(candidate: WorkspaceCleanupCandidate) => {
void dismissCandidates([candidate])
.then(() => {
if (!mountedRef.current) {
return
}
setSelectedIds((current) => {
const next = new Set(current)
next.delete(candidate.worktreeId)
return next
})
})
.catch((error: unknown) => {
if (!mountedRef.current) {
return
}
toast.error(
translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7f451a3e2c',
'Could not ignore cleanup suggestion'
),
{ description: error instanceof Error ? error.message : String(error) }
)
})
},
[dismissCandidates, mountedRef]
)
const restoreDismissals = useCallback(() => {
void resetDismissals()
}, [resetDismissals])
return {
open,
loading,
selectedIds,
setSelectedIds,
expandedRowIds,
setExpandedRowIds,
activeView,
setActiveView,
repoSelection,
setRepoSelection,
filters,
setFilters,
sortKey,
setSortKey,
sortDirection,
setSortDirection,
selectedDefaultsScanAtRef,
close: closeModal,
markCandidateViewed,
restoreDismissals,
startScan,
ignoreCandidate,
...publicRemoval
}
}
@@ -180,24 +180,26 @@ export function FacetTextField({
}
export function FacetCheckbox({
id,
label,
checked,
onChange
}: {
id: string
label: string
checked: boolean
onChange: (next: boolean) => void
}): React.JSX.Element {
const id = `workspace-cleanup-facet-${label.replace(/\W+/g, '-').toLowerCase()}`
const checkboxId = `workspace-cleanup-facet-${id}`
return (
<div className="flex items-center gap-2">
<Checkbox
id={id}
id={checkboxId}
aria-label={label}
checked={checked}
onCheckedChange={(next) => onChange(next === true)}
/>
<Label htmlFor={id} className="cursor-pointer text-xs font-normal text-foreground">
<Label htmlFor={checkboxId} className="cursor-pointer text-xs font-normal text-foreground">
{label}
</Label>
</div>
@@ -41,6 +41,14 @@ describe('facet building', () => {
expect(facets.hostId).toBe('local')
})
it('preserves a disconnected candidate execution host', () => {
const facets = makeFacets({
candidate: { connectionId: 'builder', executionHostId: 'ssh:builder' },
worktree: null
})
expect(facets.hostId).toBe('ssh:builder')
})
it('treats a missing size entry as unsized rather than zero', () => {
expect(makeFacets().sizeBytes).toBeNull()
expect(makeFacets({ sizeBytes: 0 }).sizeBytes).toBe(0)
@@ -187,6 +195,22 @@ describe('status filter', () => {
expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(true)
})
it('excludes statusless rows even when no named status is selected', () => {
const state = filters((s) => {
s.safety.dismissed = 'any'
s.status.workspaceStatuses = []
s.status.matchStatusless = false
})
expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(false)
expect(
matchesWorkspaceCleanupFilters(
makeFacets({ worktree: { workspaceStatus: 'in-review' } }),
state,
FACET_NOW
)
).toBe(true)
})
it('applies tri-state flags', () => {
const archived = makeFacets({ worktree: { isArchived: true } })
const only = filters((s) => {
@@ -64,7 +64,7 @@ export function matchesWorkspaceCleanupStatus(
facets: WorkspaceCleanupFacets,
filter: WorkspaceCleanupStatusFilter
): boolean {
if (filter.workspaceStatuses.length > 0 && !matchesWorkspaceStatusList(facets, filter)) {
if (!matchesWorkspaceStatusList(facets, filter)) {
return false
}
return (
@@ -193,7 +193,10 @@ function matchesWorkspaceStatusList(
if (facets.workspaceStatus === null) {
return filter.matchStatusless
}
return filter.workspaceStatuses.includes(facets.workspaceStatus)
return (
filter.workspaceStatuses.length === 0 ||
filter.workspaceStatuses.includes(facets.workspaceStatus)
)
}
function getIdleSignalAt(
@@ -121,7 +121,7 @@ export function buildWorkspaceCleanupFacets(
displayName: candidate.displayName,
path: candidate.path,
branch,
hostId: worktree?.hostId ?? LOCAL_EXECUTION_HOST_ID,
hostId: worktree?.hostId ?? candidate.executionHostId ?? LOCAL_EXECUTION_HOST_ID,
tier: candidate.tier,
blockers: candidate.blockers,
blockerCount: candidate.blockers.length,
@@ -33,6 +33,7 @@ describe('WorkspaceCleanupFilterBar', () => {
hasActiveFilters={false}
sizeScan={{
measuredCount: 23,
unmeasuredCount: 77,
scanning: true,
scannedCount: 23,
totalCount: 100,
@@ -48,4 +49,45 @@ describe('WorkspaceCleanupFilterBar', () => {
(screen.getByRole('button', { name: 'Measuring 23/100' }) as HTMLButtonElement).disabled
).toBe(true)
})
it('keeps measurement available while any row is unmeasured', () => {
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,
unmeasuredCount: 77,
scanning: false,
scannedCount: 23,
totalCount: 100,
onRun: vi.fn()
}}
gitEvidence={{ pendingCount: 0, totalCount: 0 }}
onQueryChange={vi.fn()}
onClearFilters={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: 'Measure sizes' })).toBeTruthy()
})
})
@@ -11,6 +11,7 @@ import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-
export type WorkspaceCleanupSizeScanState = {
measuredCount: number
unmeasuredCount: number
scanning: boolean
scannedCount: number
totalCount: number
@@ -113,7 +114,7 @@ export function WorkspaceCleanupFilterBar({
</span>
) : null}
{sizeScan.scanning || sizeScan.measuredCount === 0 ? (
{sizeScan.scanning || sizeScan.unmeasuredCount > 0 ? (
<Button variant="outline" size="sm" disabled={sizeScan.scanning} onClick={sizeScan.onRun}>
{sizeScan.scanning ? (
<Loader2 className="size-3.5 animate-spin" />
@@ -195,6 +195,7 @@ export function WorkspaceCleanupGitReviewFacets({
onChange={(dismissed) => onPatch('safety', { dismissed })}
/>
<FacetCheckbox
id="safety-selectable-only"
label={translate(
'components.workspace.cleanup.browse.selectableOnly',
'Only workspaces I can delete now'
@@ -55,6 +55,7 @@ export function WorkspaceCleanupLifecycleFacets({
onChange={(idleMinDays) => onPatch('activity', { idleMinDays })}
/>
<FacetCheckbox
id="activity-never-visited"
label={translate('components.workspace.cleanup.browse.neverVisited', 'Never opened')}
checked={filters.activity.neverVisited}
onChange={(neverVisited) => onPatch('activity', { neverVisited })}
@@ -80,6 +81,7 @@ export function WorkspaceCleanupLifecycleFacets({
onChange={(megabytes) => onPatch('size', { maxBytes: fromMegabytes(megabytes) })}
/>
<FacetCheckbox
id="size-include-unsized"
label={translate(
'components.workspace.cleanup.browse.includeUnsized',
'Include unmeasured workspaces'
@@ -105,6 +107,7 @@ export function WorkspaceCleanupLifecycleFacets({
/>
) : null}
<FacetCheckbox
id="status-match-statusless"
label={translate(
'components.workspace.cleanup.browse.matchStatusless',
'Include workspaces with no status'
@@ -181,6 +184,7 @@ export function WorkspaceCleanupLifecycleFacets({
onChange={(presence) => onPatch('context', { presence })}
/>
<FacetCheckbox
id="context-completely-empty"
label={translate(
'components.workspace.cleanup.browse.completelyEmpty',
'Nothing left to lose'
@@ -1,19 +1,27 @@
import { translate } from '@/i18n/i18n'
/** Compact "how long ago" label for cleanup rows; 0 means Orca never recorded it. */
export function formatWorkspaceCleanupRelativeTime(timestamp: number, now = Date.now()): string {
if (!timestamp) {
return 'Never'
return translate('components.workspace.cleanup.relativeTime.never', 'Never')
}
const deltaMs = now - timestamp
if (deltaMs < 60_000) {
return 'Just now'
return translate('components.workspace.cleanup.relativeTime.justNow', 'Just now')
}
const minutes = Math.floor(deltaMs / 60_000)
if (minutes < 60) {
return `${minutes}m ago`
return translate('components.workspace.cleanup.relativeTime.minutesAgo', '{{value0}}m ago', {
value0: minutes
})
}
const hours = Math.floor(minutes / 60)
if (hours < 48) {
return `${hours}h ago`
return translate('components.workspace.cleanup.relativeTime.hoursAgo', '{{value0}}h ago', {
value0: hours
})
}
return `${Math.floor(hours / 24)}d ago`
return translate('components.workspace.cleanup.relativeTime.daysAgo', '{{value0}}d ago', {
value0: Math.floor(hours / 24)
})
}
@@ -1,265 +0,0 @@
import {
useCallback,
useRef,
useState,
type Dispatch,
type RefObject,
type SetStateAction
} from 'react'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import type { AppState } from '@/store/types'
import { useAppStore } from '@/store'
import {
startWorkspaceCleanupBackgroundRemoval,
type WorkspaceCleanupRemovalProgress
} from './workspace-cleanup-background-removal'
import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates'
type WorkspaceCleanupSelectedIdsSetter = Dispatch<SetStateAction<Set<string>>>
export type WorkspaceCleanupRemovalSession = {
confirming: boolean
confirmCandidates: WorkspaceCleanupCandidate[]
removalProgress: WorkspaceCleanupRemovalProgress | null
removalInFlight: boolean
rowFailures: Record<string, string>
removalInFlightRef: RefObject<boolean>
clearRowFailures: () => void
resetForOpen: () => void
applyScanDefaults: (
candidates: readonly WorkspaceCleanupCandidate[],
deletingWorktreeIds: ReadonlySet<string>
) => void
openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void
cancelConfirmRemove: () => void
backToWorkspaceCleanupList: () => void
confirmRemove: () => void
}
export function useWorkspaceCleanupRemovalSession({
mountedRef,
setSelectedIds,
closeModal,
removeCandidates,
markWorktreesQueuedForDeletion,
clearWorktreeDeleteState
}: {
mountedRef: RefObject<boolean>
setSelectedIds: WorkspaceCleanupSelectedIdsSetter
closeModal: AppState['closeModal']
removeCandidates: AppState['removeWorkspaceCleanupCandidates']
markWorktreesQueuedForDeletion: AppState['markWorktreesQueuedForDeletion']
clearWorktreeDeleteState: AppState['clearWorktreeDeleteState']
}): WorkspaceCleanupRemovalSession {
const [confirming, setConfirming] = useState(false)
const [confirmCandidates, setConfirmCandidates] = useState<WorkspaceCleanupCandidate[]>([])
const [removalProgress, setRemovalProgress] = useState<WorkspaceCleanupRemovalProgress | null>(
null
)
// Why: progress arrives after the click; the ref synchronously rejects a second batch.
const [removalInFlight, setRemovalInFlight] = useState(false)
const [rowFailures, setRowFailures] = useState<Record<string, string>>({})
const removalInFlightRef = useRef(false)
// Why: an older batch's late settlement must not mutate a newer batch's UI.
const removalBatchIdRef = useRef(0)
const clearRowFailures = useCallback(() => setRowFailures({}), [])
const resetForOpen = useCallback(() => {
setConfirming(false)
setRowFailures({})
setSelectedIds(new Set())
}, [setSelectedIds])
const applyScanDefaults = useCallback(
(
candidates: readonly WorkspaceCleanupCandidate[],
deletingWorktreeIds: ReadonlySet<string>
) => {
if (removalInFlightRef.current) {
return
}
setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(candidates, deletingWorktreeIds))
setConfirming(false)
setRowFailures({})
},
[setSelectedIds]
)
const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => {
const nextCandidates = filterWorkspaceCleanupRemovalCandidates(
candidates,
useAppStore.getState().deleteStateByWorktreeId
)
if (nextCandidates.length === 0) {
return
}
setConfirmCandidates(nextCandidates)
setConfirming(true)
}, [])
const cancelConfirmRemove = useCallback(() => {
if (removalProgress) {
closeModal()
return
}
setConfirming(false)
setConfirmCandidates([])
}, [closeModal, removalProgress])
// Why: Back exposes per-row progress without cancelling the batch; Close dismisses the modal.
const backToWorkspaceCleanupList = useCallback(() => {
setConfirming(false)
setConfirmCandidates([])
}, [])
const clearQueuedDeleteState = useCallback(
(worktreeId: string) => {
const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId]
// Why: rows that fail before removal starts must not stay queued in the sidebar.
if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') {
clearWorktreeDeleteState(worktreeId)
}
},
[clearWorktreeDeleteState]
)
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
})
},
[setSelectedIds]
)
const confirmRemove = useCallback(() => {
if (confirmCandidates.length === 0 || removalInFlightRef.current) {
return
}
const removableCandidates = filterWorkspaceCleanupRemovalCandidates(
confirmCandidates,
useAppStore.getState().deleteStateByWorktreeId
)
if (removableCandidates.length === 0) {
setConfirming(false)
setConfirmCandidates([])
return
}
removalInFlightRef.current = true
setRemovalInFlight(true)
removalBatchIdRef.current += 1
const removalBatchId = removalBatchIdRef.current
// Why: hung late settlements retain callbacks, so capture ids instead of candidate objects.
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<string, string> = {}
for (const failure of result.failures) {
nextFailures[failure.worktreeId] = failure.message
clearQueuedDeleteState(failure.worktreeId)
}
if (mountedRef.current) {
setRowFailures(nextFailures)
deselectRemovedIds(result.removedIds)
setRemovalProgress(null)
setRemovalInFlight(false)
setConfirming(false)
setConfirmCandidates([])
}
removalInFlightRef.current = false
},
onLateResult: (result) => {
for (const failure of result.failures) {
// Why: a late preflight failure can leave a row that never started stuck as queued.
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
})
deselectRemovedIds(result.removedIds)
},
onError: () => {
// Why: the batch driver is gone, so nothing will settle these rows later; clear
// unconditionally (not just queued ones) or a hung 'deleting' row stays stuck in
// the sidebar and filterWorkspaceCleanupRemovalCandidates blocks every retry.
for (const worktreeId of removableWorktreeIds) {
clearWorktreeDeleteState(worktreeId)
}
if (mountedRef.current) {
setRemovalProgress(null)
setRemovalInFlight(false)
setConfirming(false)
setConfirmCandidates([])
}
removalInFlightRef.current = false
}
})
}, [
clearQueuedDeleteState,
clearWorktreeDeleteState,
confirmCandidates,
deselectRemovedIds,
markWorktreesQueuedForDeletion,
mountedRef,
removeCandidates
])
return {
confirming,
confirmCandidates,
removalProgress,
removalInFlight,
rowFailures,
removalInFlightRef,
clearRowFailures,
resetForOpen,
applyScanDefaults,
openConfirmRemove,
cancelConfirmRemove,
backToWorkspaceCleanupList,
confirmRemove
}
}
function getDefaultSelectedWorkspaceCleanupIds(
candidates: readonly WorkspaceCleanupCandidate[],
deletingWorktreeIds: ReadonlySet<string>
): Set<string> {
return new Set(
candidates
.filter(
(candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId)
)
.map((candidate) => candidate.worktreeId)
)
}
@@ -24,15 +24,28 @@ export function formatWorkspaceCleanupScanNotice(
if (visibleErrors.length === 1) {
const error = visibleErrors[0]
const repoName = formatScanErrorRepoName(error, repoNameById)
return `Could not check ${repoName}: ${formatScanErrorReason(error.message)}. Some workspaces may be missing. Refresh to try again.`
return translate(
'components.workspace.cleanup.scan.singleError',
'Could not check {{value0}}: {{value1}}. Some workspaces may be missing. Refresh to try again.',
{ value0: repoName, value1: formatScanErrorReason(error.message) }
)
}
const repoNames = visibleErrors
.slice(0, 3)
.map((error) => formatScanErrorRepoName(error, repoNameById))
.join(', ')
const moreCount = visibleErrors.length - 3
const suffix = moreCount > 0 ? `, +${moreCount} more` : ''
return `Could not check ${visibleErrors.length} repositories (${repoNames}${suffix}). Some workspaces may be missing. Refresh to try again.`
const suffix =
moreCount > 0
? translate('components.workspace.cleanup.scan.moreErrors', ', +{{value0}} more', {
value0: moreCount
})
: ''
return translate(
'components.workspace.cleanup.scan.multipleErrors',
'Could not check {{value0}} repositories ({{value1}}{{value2}}). Some workspaces may be missing. Refresh to try again.',
{ value0: visibleErrors.length, value1: repoNames, value2: suffix }
)
}
export function formatWorkspaceCleanupScanProgress(
@@ -56,11 +69,31 @@ export function formatWorkspaceCleanupReadyToast(
suggestedCount: number
): string {
if (workspaceCount === 0) {
return 'No workspaces found.'
return translate('components.workspace.cleanup.scan.noWorkspaces', 'No workspaces found.')
}
const workspaceNoun = workspaceCount === 1 ? 'workspace' : 'workspaces'
const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions'
return `${workspaceCount} ${workspaceNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.`
if (workspaceCount === 1) {
return suggestedCount === 1
? translate(
'components.workspace.cleanup.scan.readyOneOne',
'1 workspace found, with 1 cleanup suggestion.'
)
: translate(
'components.workspace.cleanup.scan.readyOneMany',
'1 workspace found, with {{value0}} cleanup suggestions.',
{ value0: suggestedCount }
)
}
return suggestedCount === 1
? translate(
'components.workspace.cleanup.scan.readyManyOne',
'{{value0}} workspaces found, with 1 cleanup suggestion.',
{ value0: workspaceCount }
)
: translate(
'components.workspace.cleanup.scan.readyManyMany',
'{{value0}} workspaces found, with {{value1}} cleanup suggestions.',
{ value0: workspaceCount, value1: suggestedCount }
)
}
function formatScanErrorRepoName(
@@ -72,12 +105,17 @@ function formatScanErrorRepoName(
return repoName
}
const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : ''
return fallback || 'a repository'
return (
fallback || translate('components.workspace.cleanup.scan.fallbackRepository', 'a repository')
)
}
function formatScanErrorReason(message: string | undefined): string {
if (!message || message === 'Could not scan workspace cleanup for this repository.') {
return 'Git could not list worktrees'
return translate(
'components.workspace.cleanup.scan.gitListFailed',
'Git could not list worktrees'
)
}
return message.replace(/\.$/, '')
}
@@ -1,88 +0,0 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import { toast } from 'sonner'
import type { AppState } from '@/store/types'
import { translate } from '@/i18n/i18n'
export function useWorkspaceCleanupScanSession({
open,
mountedRef,
openModal,
scanWorkspaceCleanup,
clearRowFailures
}: {
open: boolean
mountedRef: RefObject<boolean>
openModal: AppState['openModal']
scanWorkspaceCleanup: AppState['scanWorkspaceCleanup']
clearRowFailures: () => void
}): (options?: { notifyWhenReady?: boolean }) => void {
const openRef = useRef(open)
const latestReadyToastScanAtRef = useRef<number | null>(null)
useEffect(() => {
openRef.current = open
}, [open])
return useCallback(
(options: { notifyWhenReady?: boolean } = {}) => {
clearRowFailures()
void scanWorkspaceCleanup()
.then((result) => {
if (!mountedRef.current || !options.notifyWhenReady || openRef.current) {
return
}
if (latestReadyToastScanAtRef.current === result.scannedAt) {
return
}
latestReadyToastScanAtRef.current = result.scannedAt
const suggestedCount = result.candidates.filter(
(candidate) => candidate.selectedByDefault
).length
toast.success(
translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63',
'Inactive workspace scan ready'
),
{
description: formatWorkspaceCleanupReadyToastDescription(
result.candidates.length,
suggestedCount
),
action: {
label: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764',
'Review'
),
onClick: () => openModal('workspace-cleanup')
}
}
)
})
.catch((error: unknown) => {
if (!mountedRef.current) {
return
}
toast.error(
translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8',
'Workspace cleanup scan failed'
),
{ description: error instanceof Error ? error.message : String(error) }
)
})
},
[clearRowFailures, mountedRef, openModal, scanWorkspaceCleanup]
)
}
function formatWorkspaceCleanupReadyToastDescription(
inactiveCount: number,
suggestedCount: number
): string {
if (inactiveCount === 0) {
return 'No inactive workspaces found.'
}
const inactiveNoun = inactiveCount === 1 ? 'workspace' : 'workspaces'
const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions'
return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.`
}
+30 -1
View File
@@ -15391,7 +15391,29 @@
"openWorkspace": "Open workspace",
"workspaceStatus": "Workspace status: {{value0}}",
"measuringSizesProgress": "Measuring {{value0}}/{{value1}}",
"measuringSizes": "Measuring sizes"
"measuringSizes": "Measuring sizes",
"measureSizesFailed": "Could not measure workspace sizes",
"selectedCount": "{{value0}} selected"
},
"scan": {
"progress": "Scanning workspaces ({{value0}}/{{value1}})",
"singleError": "Could not check {{value0}}: {{value1}}. Some workspaces may be missing. Refresh to try again.",
"moreErrors": ", +{{value0}} more",
"multipleErrors": "Could not check {{value0}} repositories ({{value1}}{{value2}}). Some workspaces may be missing. Refresh to try again.",
"noWorkspaces": "No workspaces found.",
"readyOneOne": "1 workspace found, with 1 cleanup suggestion.",
"readyOneMany": "1 workspace found, with {{value0}} cleanup suggestions.",
"readyManyOne": "{{value0}} workspaces found, with 1 cleanup suggestion.",
"readyManyMany": "{{value0}} workspaces found, with {{value1}} cleanup suggestions.",
"fallbackRepository": "a repository",
"gitListFailed": "Git could not list worktrees"
},
"relativeTime": {
"never": "Never",
"justNow": "Just now",
"minutesAgo": "{{value0}}m ago",
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago"
}
}
},
@@ -15420,6 +15442,13 @@
"sent": "Session context sent to {{agent}} in a new session.",
"deliveryFailed": "The new {{agent}} session started, but its context could not be sent.",
"launchFailed": "Could not start a new {{agent}} session."
},
"status": {
"bar": {
"workspaceSpace": {
"otherTopLevelItems": "Other top-level items ({{value0}})"
}
}
}
},
"dashboardPopout": {
@@ -570,6 +570,25 @@ describe('workspace cleanup scan progress', () => {
await expect(Promise.all([legacy, full])).resolves.toHaveLength(2)
})
it('still joins the first key while a different scan key is running', async () => {
const firstPending = deferred<WorkspaceCleanupScanResult>()
const secondPending = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) =>
args?.skipGitWorktreeIds?.includes('second') ? secondPending.promise : firstPending.promise
)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const first = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['first'] })
const second = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['second'] })
const firstAgain = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['first'] })
expect(scan).toHaveBeenCalledTimes(2)
firstPending.resolve({ scannedAt: NOW, candidates: [], errors: [] })
secondPending.resolve({ scannedAt: NOW, candidates: [], errors: [] })
await expect(Promise.all([first, second, firstAgain])).resolves.toHaveLength(3)
})
it('keeps stale cleanup results visible after a broad refresh failure', async () => {
const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
const scan = vi
@@ -90,10 +90,7 @@ const WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY = 4
// unverifiable must still fail if real work becomes visible before removal.
const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const
let inFlightWorkspaceCleanupScan: {
key: string
promise: Promise<WorkspaceCleanupScanResult>
} | null = null
const inFlightWorkspaceCleanupScans = new Map<string, Promise<WorkspaceCleanupScanResult>>()
let latestWorkspaceCleanupScanToken = 0
let finalizedWorkspaceCleanupScanToken = 0
let workspaceCleanupProgressQueue: {
@@ -176,12 +173,13 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
}
const scanKey = getWorkspaceCleanupScanKey(scanArgs)
if (inFlightWorkspaceCleanupScan?.key === scanKey) {
const existingInFlight = inFlightWorkspaceCleanupScans.get(scanKey)
if (existingInFlight) {
set({ workspaceCleanupLoading: true, workspaceCleanupError: null })
try {
return await inFlightWorkspaceCleanupScan.promise
return await existingInFlight
} finally {
if (!inFlightWorkspaceCleanupScan) {
if (!inFlightWorkspaceCleanupScans.has(scanKey)) {
set({ workspaceCleanupLoading: false })
}
}
@@ -226,7 +224,7 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
}
return result
})()
inFlightWorkspaceCleanupScan = { key: scanKey, promise }
inFlightWorkspaceCleanupScans.set(scanKey, promise)
try {
return await promise
@@ -237,8 +235,8 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
}
throw error
} finally {
if (inFlightWorkspaceCleanupScan?.promise === promise) {
inFlightWorkspaceCleanupScan = null
if (inFlightWorkspaceCleanupScans.get(scanKey) === promise) {
inFlightWorkspaceCleanupScans.delete(scanKey)
}
}
},
@@ -404,7 +402,7 @@ function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string {
function invalidateWorkspaceCleanupScanProgress(): void {
latestWorkspaceCleanupScanToken += 1
finalizedWorkspaceCleanupScanToken = 0
inFlightWorkspaceCleanupScan = null
inFlightWorkspaceCleanupScans.clear()
workspaceCleanupProgressQueue = null
workspaceCleanupEnrichmentCache = null
workspaceCleanupProgressCandidateIndex = null