mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
fix(workspace-cleanup): address review feedback
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
+3
-1
@@ -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
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
+30
-3
@@ -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}
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+7
-2
@@ -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,
|
||||
|
||||
+12
@@ -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(
|
||||
|
||||
+9
-9
@@ -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"
|
||||
|
||||
+26
@@ -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()
|
||||
})
|
||||
})
|
||||
-175
@@ -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}.`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user