mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
feat(workspaces): rework cleanup dialog
This commit is contained in:
@@ -264,7 +264,6 @@ inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { ReactNode, Ref } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { createTestStore, seedStore } from '../../store/slices/store-test-helpers'
|
||||
import { resetWorkspaceCleanupBrowsePersistTimer } from '../../store/slices/workspace-cleanup-browse'
|
||||
import { NOW, makeCandidate } from '../../store/slices/workspace-cleanup-slice-test-harness'
|
||||
import WorkspaceCleanupDialog from './WorkspaceCleanupDialog'
|
||||
|
||||
const holders = vi.hoisted(() => ({
|
||||
store: null as unknown as { (selector: (s: unknown) => unknown): unknown } & {
|
||||
getState: () => Record<string, unknown>
|
||||
setState: (partial: unknown) => void
|
||||
subscribe: (listener: (state: unknown, previous: unknown) => void) => () => void
|
||||
},
|
||||
infoToasts: [] as string[],
|
||||
rig: null as null | {
|
||||
resolvers: ((result: { scannedAt: number; candidates: never[]; errors: never[] }) => void)[]
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = (selector: (s: unknown) => unknown): unknown => holders.store(selector)
|
||||
useAppStore.getState = () => holders.store.getState()
|
||||
useAppStore.setState = (partial: unknown) => holders.store.setState(partial)
|
||||
useAppStore.subscribe = (listener: (state: unknown, previous: unknown) => void) =>
|
||||
holders.store.subscribe(listener)
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: (message: unknown) => holders.infoToasts.push(String(message))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() }))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/scroll-area', () => ({
|
||||
ScrollArea: ({ children, viewportRef }: { children: ReactNode; viewportRef?: Ref<unknown> }) => (
|
||||
<div ref={viewportRef as Ref<HTMLDivElement>}>{children}</div>
|
||||
),
|
||||
ScrollBar: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/popover', () => ({
|
||||
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
PopoverContent: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuContent: () => null,
|
||||
DropdownMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
const CACHED_AT = NOW - 2 * 60 * 60 * 1000
|
||||
|
||||
type ScanRig = {
|
||||
scan: ReturnType<typeof vi.fn>
|
||||
progressCallbacks: ((progress: WorkspaceCleanupScanProgress) => void)[]
|
||||
resolvers: ((result: WorkspaceCleanupScanResult) => void)[]
|
||||
}
|
||||
|
||||
function installApi(cachedScan: WorkspaceCleanupScanResult | null): ScanRig {
|
||||
const rig: ScanRig = { scan: vi.fn(), progressCallbacks: [], resolvers: [] }
|
||||
rig.scan.mockImplementation(
|
||||
(
|
||||
args?: { worktreeId?: string },
|
||||
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
) => {
|
||||
if (args?.worktreeId) {
|
||||
return Promise.resolve({
|
||||
scannedAt: NOW,
|
||||
candidates: [makeCandidate({ worktreeId: args.worktreeId })],
|
||||
errors: []
|
||||
})
|
||||
}
|
||||
if (onProgress) {
|
||||
rig.progressCallbacks.push(onProgress)
|
||||
}
|
||||
return new Promise<WorkspaceCleanupScanResult>((resolve) => {
|
||||
rig.resolvers.push(resolve)
|
||||
})
|
||||
}
|
||||
)
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
workspaceCleanup: {
|
||||
scan: rig.scan,
|
||||
getCachedScan: vi.fn().mockResolvedValue(cachedScan),
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
clearDismissals: vi.fn().mockResolvedValue(undefined),
|
||||
hasKillableLocalProcesses: vi.fn().mockResolvedValue({ hasKillableProcesses: false })
|
||||
},
|
||||
workspaceSpace: {
|
||||
getCachedAnalysis: vi.fn().mockResolvedValue(null),
|
||||
analyze: vi.fn(),
|
||||
cancel: vi.fn().mockResolvedValue(false),
|
||||
onProgress: vi.fn().mockReturnValue(() => undefined)
|
||||
},
|
||||
ui: { set: vi.fn().mockResolvedValue(undefined) }
|
||||
}
|
||||
holders.rig = rig as unknown as typeof holders.rig
|
||||
return rig
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
}
|
||||
|
||||
async function renderDialog(): Promise<void> {
|
||||
await act(async () => {
|
||||
root?.render(<WorkspaceCleanupDialog />)
|
||||
})
|
||||
}
|
||||
|
||||
async function openDialog(): Promise<void> {
|
||||
await act(async () => {
|
||||
holders.store.setState({ activeModal: 'workspace-cleanup' })
|
||||
})
|
||||
await flush()
|
||||
}
|
||||
|
||||
function rowNames(): string[] {
|
||||
return [...(container?.querySelectorAll('span.truncate.text-sm.font-medium') ?? [])].map(
|
||||
(node) => node.textContent ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function rowCheckbox(name: string): HTMLElement | null {
|
||||
return container?.querySelector<HTMLElement>(`[aria-label="Select ${name}"]`) ?? null
|
||||
}
|
||||
|
||||
function cachedFleet(): WorkspaceCleanupScanResult {
|
||||
return {
|
||||
scannedAt: CACHED_AT,
|
||||
candidates: [
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' }),
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/beta', displayName: 'beta' })
|
||||
],
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
function progressTick(
|
||||
rig: ScanRig,
|
||||
progress: Partial<WorkspaceCleanupScanProgress> & Pick<WorkspaceCleanupScanProgress, 'candidates'>
|
||||
): void {
|
||||
rig.progressCallbacks.at(-1)?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: progress.candidates.length,
|
||||
totalWorktreeCount: 3,
|
||||
errors: [],
|
||||
candidateMode: 'append',
|
||||
...progress
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspaceCleanupDialog stale-while-revalidate', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
// Why Date-only fake: relative "Updated Xh ago" labels need a pinned clock,
|
||||
// but flush() depends on real setTimeout.
|
||||
vi.useFakeTimers({ toFake: ['Date'], now: NOW })
|
||||
const store = createTestStore()
|
||||
seedStore(store, {})
|
||||
holders.store = store as unknown as typeof holders.store
|
||||
holders.infoToasts = []
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
// Why: broad-scan dedupe lives in slice module state; an unresolved scan
|
||||
// left by one case would silently join into the next case's scan call.
|
||||
for (const resolve of holders.rig?.resolvers ?? []) {
|
||||
resolve({ scannedAt: NOW, candidates: [], errors: [] })
|
||||
}
|
||||
holders.rig = null
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
resetWorkspaceCleanupBrowsePersistTimer()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders the cached snapshot immediately and kicks one background rescan', async () => {
|
||||
const rig = installApi(cachedFleet())
|
||||
await renderDialog()
|
||||
await openDialog()
|
||||
|
||||
// Full list from cache while the rescan is still streaming (unresolved).
|
||||
expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta']))
|
||||
expect(rig.scan).toHaveBeenCalledTimes(1)
|
||||
expect(container?.textContent).toContain('Updated 2h ago')
|
||||
expect(container?.textContent).toContain('Refreshing')
|
||||
})
|
||||
|
||||
it('reconciles streamed rows into the cached list without clearing it', async () => {
|
||||
const rig = installApi(cachedFleet())
|
||||
await renderDialog()
|
||||
await openDialog()
|
||||
|
||||
progressTick(rig, {
|
||||
candidates: [
|
||||
makeCandidate({
|
||||
worktreeId: 'repo1::/tmp/alpha',
|
||||
displayName: 'alpha',
|
||||
fingerprint: 'fingerprint-alpha-2'
|
||||
}),
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })
|
||||
],
|
||||
scannedWorktreeCount: 2
|
||||
})
|
||||
await flush()
|
||||
|
||||
// beta was not re-reported yet; it must still be on screen mid-refresh.
|
||||
expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma']))
|
||||
})
|
||||
|
||||
it('keeps search and selection interactive while rows stream in', async () => {
|
||||
const rig = installApi(cachedFleet())
|
||||
await renderDialog()
|
||||
await openDialog()
|
||||
|
||||
const search = container?.querySelector<HTMLInputElement>('[aria-label="Search workspaces"]')
|
||||
expect(search?.disabled).toBe(false)
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
||||
await act(async () => {
|
||||
setter?.call(search, 'beta')
|
||||
search?.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
progressTick(rig, {
|
||||
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })],
|
||||
scannedWorktreeCount: 1
|
||||
})
|
||||
await flush()
|
||||
|
||||
// Filter applied mid-stream: only beta visible even as new rows arrive.
|
||||
expect(rowNames()).toEqual(['beta'])
|
||||
|
||||
await act(async () => {
|
||||
setter?.call(search, '')
|
||||
search?.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
await flush()
|
||||
expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma']))
|
||||
|
||||
// Rows stay selectable during the stream.
|
||||
const checkbox = rowCheckbox('alpha')
|
||||
expect(checkbox).not.toBeNull()
|
||||
await act(async () => {
|
||||
checkbox?.click()
|
||||
})
|
||||
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true')
|
||||
})
|
||||
|
||||
it('adopts the in-flight scan on reopen instead of starting a duplicate', async () => {
|
||||
const rig = installApi(cachedFleet())
|
||||
await renderDialog()
|
||||
await openDialog()
|
||||
expect(rig.scan).toHaveBeenCalledTimes(1)
|
||||
|
||||
progressTick(rig, {
|
||||
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })],
|
||||
scannedWorktreeCount: 1
|
||||
})
|
||||
await flush()
|
||||
|
||||
// Close at 40% and reopen: the same scan keeps streaming into the store.
|
||||
await act(async () => {
|
||||
holders.store.setState({ activeModal: null })
|
||||
})
|
||||
await openDialog()
|
||||
expect(rig.scan).toHaveBeenCalledTimes(1)
|
||||
|
||||
progressTick(rig, {
|
||||
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/delta', displayName: 'delta' })],
|
||||
scannedWorktreeCount: 2
|
||||
})
|
||||
await flush()
|
||||
await act(async () => {
|
||||
rig.resolvers.at(-1)?.({
|
||||
scannedAt: NOW,
|
||||
candidates: [
|
||||
...cachedFleet().candidates,
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' }),
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/delta', displayName: 'delta' })
|
||||
],
|
||||
errors: []
|
||||
})
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(rig.scan).toHaveBeenCalledTimes(1)
|
||||
expect(rowNames()).toEqual(expect.arrayContaining(['alpha', 'beta', 'gamma', 'delta']))
|
||||
})
|
||||
|
||||
it('keeps selections across a refresh and surfaces vanished selections', async () => {
|
||||
const rig = installApi(cachedFleet())
|
||||
await renderDialog()
|
||||
await openDialog()
|
||||
await act(async () => {
|
||||
rig.resolvers.at(-1)?.(cachedFleet())
|
||||
})
|
||||
await flush()
|
||||
|
||||
// Both rows are default-selected suggestions after the first settle.
|
||||
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true')
|
||||
expect(rowCheckbox('beta')?.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
await act(async () => {
|
||||
container?.querySelector<HTMLElement>('[aria-label="Refresh"]')?.click()
|
||||
})
|
||||
await flush()
|
||||
expect(rig.scan).toHaveBeenCalledTimes(2)
|
||||
// Selection survives while the refresh streams.
|
||||
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
await act(async () => {
|
||||
rig.resolvers.at(-1)?.({
|
||||
scannedAt: NOW + 1,
|
||||
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' })],
|
||||
errors: []
|
||||
})
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true')
|
||||
expect(rowNames()).not.toContain('beta')
|
||||
expect(holders.infoToasts).toContain('1 selected workspace no longer exists.')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { WorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state'
|
||||
import {
|
||||
createDefaultWorkspaceCleanupFilterState,
|
||||
DEFAULT_WORKSPACE_CLEANUP_SORT,
|
||||
type WorkspaceCleanupFilterState,
|
||||
type WorkspaceCleanupSortField,
|
||||
type WorkspaceCleanupSortState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import {
|
||||
applyWorkspaceCleanupPreset,
|
||||
listWorkspaceCleanupPresets,
|
||||
matchWorkspaceCleanupPresetId
|
||||
} from '../../../../shared/workspace-cleanup-preset-state'
|
||||
import type { WorkspaceCleanupPreset } from '../../../../shared/workspace-cleanup-presets'
|
||||
|
||||
export type WorkspaceCleanupBrowseController = {
|
||||
browse: WorkspaceCleanupBrowseState
|
||||
filters: WorkspaceCleanupFilterState
|
||||
sort: WorkspaceCleanupSortState
|
||||
presets: WorkspaceCleanupPreset[]
|
||||
/** Preset the current state still matches; null once the user edits away from one. */
|
||||
matchedPresetId: string | null
|
||||
setFilters: (next: WorkspaceCleanupFilterState) => void
|
||||
patchFilters: <K extends keyof WorkspaceCleanupFilterState>(
|
||||
key: K,
|
||||
value: Partial<WorkspaceCleanupFilterState[K]> | WorkspaceCleanupFilterState[K]
|
||||
) => void
|
||||
setSort: (next: WorkspaceCleanupSortState) => void
|
||||
toggleSortField: (field: WorkspaceCleanupSortField) => void
|
||||
applyPreset: (preset: WorkspaceCleanupPreset) => void
|
||||
clearFilters: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Single seam over the persisted browse slice so the dialog never owns filter,
|
||||
* sort, or preset state — reopening the dialog must not discard the user's view.
|
||||
*/
|
||||
export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseController {
|
||||
const browse = useAppStore((s) => s.workspaceCleanupBrowse)
|
||||
const updateBrowse = useAppStore((s) => s.updateWorkspaceCleanupBrowseState)
|
||||
|
||||
const presets = useMemo(
|
||||
() => listWorkspaceCleanupPresets(browse.customPresets),
|
||||
[browse.customPresets]
|
||||
)
|
||||
const matchedPresetId = useMemo(
|
||||
() =>
|
||||
matchWorkspaceCleanupPresetId(
|
||||
{ filters: browse.filters, sort: browse.sort },
|
||||
browse.customPresets
|
||||
),
|
||||
[browse.customPresets, browse.filters, browse.sort]
|
||||
)
|
||||
|
||||
const setFilters = useCallback(
|
||||
(next: WorkspaceCleanupFilterState) => {
|
||||
updateBrowse({ ...browse, filters: next })
|
||||
},
|
||||
[browse, updateBrowse]
|
||||
)
|
||||
|
||||
const patchFilters = useCallback<WorkspaceCleanupBrowseController['patchFilters']>(
|
||||
(key, value) => {
|
||||
const current = browse.filters[key]
|
||||
const next =
|
||||
typeof current === 'object' && current !== null
|
||||
? { ...current, ...(value as object) }
|
||||
: value
|
||||
// Cast: a computed key over a union widens the spread result past
|
||||
// WorkspaceCleanupFilterState even though `key` is constrained to it.
|
||||
const filters = { ...browse.filters, [key]: next } as WorkspaceCleanupFilterState
|
||||
updateBrowse({ ...browse, filters })
|
||||
},
|
||||
[browse, updateBrowse]
|
||||
)
|
||||
|
||||
const setSort = useCallback(
|
||||
(next: WorkspaceCleanupSortState) => {
|
||||
updateBrowse({ ...browse, sort: next })
|
||||
},
|
||||
[browse, updateBrowse]
|
||||
)
|
||||
|
||||
// Why: re-picking the active column flips direction, which is the table
|
||||
// convention users expect from a sortable header.
|
||||
const toggleSortField = useCallback(
|
||||
(field: WorkspaceCleanupSortField) => {
|
||||
const direction =
|
||||
browse.sort.field === field && browse.sort.direction === 'asc' ? 'desc' : 'asc'
|
||||
updateBrowse({ ...browse, sort: { field, direction } })
|
||||
},
|
||||
[browse, updateBrowse]
|
||||
)
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(preset: WorkspaceCleanupPreset) => {
|
||||
const applied = applyWorkspaceCleanupPreset(preset)
|
||||
updateBrowse({ ...browse, activePresetId: preset.id, ...applied })
|
||||
},
|
||||
[browse, updateBrowse]
|
||||
)
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
updateBrowse({
|
||||
...browse,
|
||||
activePresetId: null,
|
||||
filters: createDefaultWorkspaceCleanupFilterState(),
|
||||
sort: { ...DEFAULT_WORKSPACE_CLEANUP_SORT }
|
||||
})
|
||||
}, [browse, updateBrowse])
|
||||
|
||||
return {
|
||||
browse,
|
||||
filters: browse.filters,
|
||||
sort: browse.sort,
|
||||
presets,
|
||||
matchedPresetId,
|
||||
setFilters,
|
||||
patchFilters,
|
||||
setSort,
|
||||
toggleSortField,
|
||||
applyPreset,
|
||||
clearFilters
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import type { HostedReviewProvider } from '../../../../shared/hosted-review'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import type {
|
||||
WorkspaceCleanupFilterState,
|
||||
WorkspaceCleanupSortState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import {
|
||||
buildWorkspaceCleanupFacetList,
|
||||
buildWorkspaceCleanupSizeIndex,
|
||||
buildWorkspaceCleanupWorktreeIndex,
|
||||
type WorkspaceCleanupFacets
|
||||
} from './workspace-cleanup-facets'
|
||||
import {
|
||||
countWorkspaceCleanupFacetMatches,
|
||||
runWorkspaceCleanupQuery
|
||||
} from './workspace-cleanup-query'
|
||||
import {
|
||||
getWorkspaceCleanupReviewInfo,
|
||||
type WorkspaceCleanupReviewInfo
|
||||
} from './workspace-cleanup-presentation'
|
||||
import type {
|
||||
WorkspaceCleanupFacetCounts,
|
||||
WorkspaceCleanupFacetOptions
|
||||
} from './workspace-cleanup-facet-panel-model'
|
||||
|
||||
export type WorkspaceCleanupFacetRows = {
|
||||
rows: WorkspaceCleanupFacets[]
|
||||
selectableWorktreeIds: string[]
|
||||
matchedCount: number
|
||||
totalCount: number
|
||||
facetCounts: WorkspaceCleanupFacetCounts
|
||||
options: WorkspaceCleanupFacetOptions
|
||||
reviewInfoByWorktreeId: ReadonlyMap<string, WorkspaceCleanupReviewInfo>
|
||||
sizeByWorktreeId: ReadonlyMap<string, number>
|
||||
/** 0 means the workspace-space scan has never produced a usable size. */
|
||||
measuredSizeCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins the scan rows with everything the flat list filters and sorts on:
|
||||
* renderer-only signals (visits, live agents, review cache, dismissals) plus
|
||||
* sizes from the EXISTING workspace-space scan — no second scanner.
|
||||
*/
|
||||
export function useWorkspaceCleanupFacetRows({
|
||||
candidates,
|
||||
filters,
|
||||
sort,
|
||||
now
|
||||
}: {
|
||||
candidates: readonly WorkspaceCleanupCandidate[]
|
||||
filters: WorkspaceCleanupFilterState
|
||||
sort: WorkspaceCleanupSortState
|
||||
now: number
|
||||
}): WorkspaceCleanupFacetRows {
|
||||
const sources = useAppStore(
|
||||
useShallow((s) => ({
|
||||
worktreesByRepo: s.worktreesByRepo,
|
||||
hostedReviewCache: s.hostedReviewCache,
|
||||
repos: s.repos,
|
||||
settings: s.settings,
|
||||
// Statuses are a top-level UI-slice field, not part of GlobalSettings.
|
||||
workspaceStatuses: s.workspaceStatuses,
|
||||
lastVisitedAtByWorktreeId: s.lastVisitedAtByWorktreeId,
|
||||
agentStatusByPaneKey: s.agentStatusByPaneKey,
|
||||
tabsByWorktree: s.tabsByWorktree,
|
||||
dismissals: s.workspaceCleanupDismissals,
|
||||
spaceWorktrees: s.workspaceSpaceAnalysis?.worktrees ?? null
|
||||
}))
|
||||
)
|
||||
|
||||
const reviewInfoByWorktreeId = useMemo(() => {
|
||||
const infos = new Map<string, WorkspaceCleanupReviewInfo>()
|
||||
for (const candidate of candidates) {
|
||||
infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, sources))
|
||||
}
|
||||
return infos
|
||||
}, [candidates, sources])
|
||||
|
||||
const sizeByWorktreeId = useMemo(
|
||||
() => buildWorkspaceCleanupSizeIndex(sources.spaceWorktrees),
|
||||
[sources.spaceWorktrees]
|
||||
)
|
||||
|
||||
const facets = useMemo(
|
||||
() =>
|
||||
buildWorkspaceCleanupFacetList(candidates, {
|
||||
worktreeById: buildWorkspaceCleanupWorktreeIndex(sources.worktreesByRepo),
|
||||
sizeBytesByWorktreeId: sizeByWorktreeId,
|
||||
lastVisitedAtByWorktreeId: sources.lastVisitedAtByWorktreeId,
|
||||
liveAgentStatusByWorktreeId: getLiveAgentStatusByWorktreeId(
|
||||
sources.agentStatusByPaneKey,
|
||||
sources.tabsByWorktree,
|
||||
now
|
||||
),
|
||||
reviewInfoByWorktreeId,
|
||||
dismissedWorktreeIds: new Set(Object.keys(sources.dismissals))
|
||||
}),
|
||||
[candidates, now, reviewInfoByWorktreeId, sizeByWorktreeId, sources]
|
||||
)
|
||||
|
||||
const result = useMemo(
|
||||
() => runWorkspaceCleanupQuery(facets, { filters, sort }, now),
|
||||
[facets, filters, now, sort]
|
||||
)
|
||||
const facetCounts = useMemo(
|
||||
() => countWorkspaceCleanupFacetMatches(facets, filters, now),
|
||||
[facets, filters, now]
|
||||
)
|
||||
|
||||
const options = useMemo<WorkspaceCleanupFacetOptions>(
|
||||
() => ({
|
||||
workspaceStatuses: sources.workspaceStatuses.map((status) => ({
|
||||
id: status.id,
|
||||
label: status.label
|
||||
})),
|
||||
hostIds: [...new Set(facets.map((row) => row.hostId))].sort(),
|
||||
repos: sources.repos
|
||||
.filter((repo) => isGitRepoKind(repo))
|
||||
.map((repo) => ({ id: repo.id, label: repo.displayName || repo.path })),
|
||||
reviewProviders: [
|
||||
...new Set(
|
||||
facets
|
||||
.map((row) => row.review.provider)
|
||||
.filter((provider): provider is HostedReviewProvider => provider !== null)
|
||||
)
|
||||
].sort()
|
||||
}),
|
||||
[facets, sources.repos, sources.workspaceStatuses]
|
||||
)
|
||||
|
||||
return {
|
||||
rows: result.rows,
|
||||
selectableWorktreeIds: result.selectableWorktreeIds,
|
||||
matchedCount: result.matchedCount,
|
||||
totalCount: result.totalCount,
|
||||
facetCounts,
|
||||
options,
|
||||
reviewInfoByWorktreeId,
|
||||
sizeByWorktreeId,
|
||||
measuredSizeCount: sizeByWorktreeId.size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
selectWorkspaceCleanupGitEvidenceTargets,
|
||||
WORKSPACE_CLEANUP_GIT_EVIDENCE_CONCURRENCY
|
||||
} from './workspace-cleanup-git-evidence'
|
||||
|
||||
export type WorkspaceCleanupGitEvidenceState = {
|
||||
/** Focused re-scan results, keyed by worktree id. */
|
||||
evidenceByWorktreeId: ReadonlyMap<string, WorkspaceCleanupCandidate>
|
||||
pendingWorktreeIds: ReadonlySet<string>
|
||||
checkedCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
const EMPTY_EVIDENCE: WorkspaceCleanupGitEvidenceState = {
|
||||
evidenceByWorktreeId: new Map(),
|
||||
pendingWorktreeIds: new Set(),
|
||||
checkedCount: 0,
|
||||
totalCount: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in git evidence the broad scan deferred, one focused re-scan per row.
|
||||
* Only runs while a git-dependent filter or sort is active — the initial list
|
||||
* render never waits on it, so an unfiltered browse stays instant.
|
||||
*/
|
||||
export function useWorkspaceCleanupGitEvidence({
|
||||
enabled,
|
||||
candidates
|
||||
}: {
|
||||
enabled: boolean
|
||||
candidates: readonly WorkspaceCleanupCandidate[]
|
||||
}): WorkspaceCleanupGitEvidenceState {
|
||||
const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup)
|
||||
const mountedRef = useMountedRef()
|
||||
const [state, setState] = useState<WorkspaceCleanupGitEvidenceState>(EMPTY_EVIDENCE)
|
||||
const evidenceRef = useRef(new Map<string, WorkspaceCleanupCandidate>())
|
||||
const attemptedRef = useRef(new Set<string>())
|
||||
const queueRef = useRef<string[]>([])
|
||||
const inFlightRef = useRef(new Set<string>())
|
||||
const totalRef = useRef(0)
|
||||
// Why: results that land after the user cleared the git filter belong to a
|
||||
// stale pass and must not resurrect its pending banner.
|
||||
const generationRef = useRef(0)
|
||||
|
||||
const publish = useCallback(() => {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setState({
|
||||
evidenceByWorktreeId: new Map(evidenceRef.current),
|
||||
pendingWorktreeIds: new Set(inFlightRef.current),
|
||||
checkedCount: totalRef.current - queueRef.current.length - inFlightRef.current.size,
|
||||
totalCount: totalRef.current
|
||||
})
|
||||
}, [mountedRef])
|
||||
|
||||
const pump = useCallback(() => {
|
||||
const generation = generationRef.current
|
||||
while (
|
||||
inFlightRef.current.size < WORKSPACE_CLEANUP_GIT_EVIDENCE_CONCURRENCY &&
|
||||
queueRef.current.length > 0
|
||||
) {
|
||||
const worktreeId = queueRef.current.shift()
|
||||
if (worktreeId === undefined) {
|
||||
break
|
||||
}
|
||||
inFlightRef.current.add(worktreeId)
|
||||
attemptedRef.current.add(worktreeId)
|
||||
void scanWorkspaceCleanup({ worktreeId })
|
||||
.then((result) => {
|
||||
const refreshed = result.candidates.find(
|
||||
(candidate) => candidate.worktreeId === worktreeId
|
||||
)
|
||||
if (refreshed && generation === generationRef.current) {
|
||||
evidenceRef.current.set(worktreeId, refreshed)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Why: a failed focused scan leaves the row honestly "Not checked";
|
||||
// attemptedRef keeps it from retrying in a loop.
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightRef.current.delete(worktreeId)
|
||||
if (generation !== generationRef.current) {
|
||||
return
|
||||
}
|
||||
publish()
|
||||
pump()
|
||||
})
|
||||
}
|
||||
publish()
|
||||
}, [publish, scanWorkspaceCleanup])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
generationRef.current += 1
|
||||
queueRef.current = []
|
||||
inFlightRef.current.clear()
|
||||
totalRef.current = 0
|
||||
publish()
|
||||
return
|
||||
}
|
||||
const targets = selectWorkspaceCleanupGitEvidenceTargets(candidates, {
|
||||
resolvedWorktreeIds: attemptedRef.current
|
||||
}).filter((id) => !queueRef.current.includes(id))
|
||||
if (targets.length === 0) {
|
||||
return
|
||||
}
|
||||
queueRef.current.push(...targets)
|
||||
totalRef.current += targets.length
|
||||
pump()
|
||||
}, [candidates, enabled, pump, publish])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
startWorkspaceCleanupBackgroundRemoval,
|
||||
type WorkspaceCleanupRemovalProgress
|
||||
} from './workspace-cleanup-background-removal'
|
||||
import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates'
|
||||
|
||||
export type WorkspaceCleanupRemovalController = {
|
||||
confirming: boolean
|
||||
confirmCandidates: WorkspaceCleanupCandidate[]
|
||||
removalProgress: WorkspaceCleanupRemovalProgress | null
|
||||
removalInFlight: boolean
|
||||
/** Synchronous guard; state alone lags a second confirm click. */
|
||||
removalInFlightRef: { current: boolean }
|
||||
rowFailures: Record<string, string>
|
||||
resetRowFailures: () => void
|
||||
resetForReopen: () => void
|
||||
openConfirmRemove: (candidates: readonly WorkspaceCleanupCandidate[]) => void
|
||||
confirmRemove: () => void
|
||||
cancelConfirmRemove: () => void
|
||||
backToList: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the destructive path: confirm gate, background batch, late settlement,
|
||||
* and the queued-delete overlay each row shows in the sidebar.
|
||||
*/
|
||||
export function useWorkspaceCleanupRemoval({
|
||||
onDeselect,
|
||||
closeModal
|
||||
}: {
|
||||
onDeselect: (removedIds: readonly string[]) => void
|
||||
closeModal: () => void
|
||||
}): WorkspaceCleanupRemovalController {
|
||||
const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates)
|
||||
const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion)
|
||||
const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState)
|
||||
const mountedRef = useMountedRef()
|
||||
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [confirmCandidates, setConfirmCandidates] = useState<WorkspaceCleanupCandidate[]>([])
|
||||
const [removalProgress, setRemovalProgress] = useState<WorkspaceCleanupRemovalProgress | null>(
|
||||
null
|
||||
)
|
||||
// Why: `removalProgress` only arrives once the batch reports, so rendering
|
||||
// needs its own in-flight flag; removalInFlightRef stays the synchronous guard.
|
||||
const [removalInFlight, setRemovalInFlight] = useState(false)
|
||||
const [rowFailures, setRowFailures] = useState<Record<string, string>>({})
|
||||
const removalInFlightRef = useRef(false)
|
||||
// Why: the dialog stays mounted across cleanup runs, so late settlements from
|
||||
// an earlier batch must not mutate a newer batch's row state.
|
||||
const removalBatchIdRef = useRef(0)
|
||||
|
||||
const resetRowFailures = useCallback(() => setRowFailures({}), [])
|
||||
|
||||
const resetForReopen = useCallback(() => {
|
||||
if (removalInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
setConfirming(false)
|
||||
setRowFailures({})
|
||||
}, [])
|
||||
|
||||
const clearQueuedDeleteState = useCallback(
|
||||
(worktreeId: string) => {
|
||||
const deleteState = useAppStore.getState().deleteStateByWorktreeId[worktreeId]
|
||||
// Why: candidates that fail before removal starts would otherwise stay
|
||||
// marked "Queued for deletion" in the sidebar; rows already in the
|
||||
// 'deleting' phase or failed with an error keep their own state.
|
||||
if (deleteState?.isDeleting && deleteState.error === null && deleteState.phase === 'queued') {
|
||||
clearWorktreeDeleteState(worktreeId)
|
||||
}
|
||||
},
|
||||
[clearWorktreeDeleteState]
|
||||
)
|
||||
|
||||
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: the header X reads as "leave this screen", not "abandon the dialog". The
|
||||
// batch keeps running either way and the list shows each row's progress.
|
||||
// Diverges from cancelConfirmRemove, which closes the dialog mid-batch: here
|
||||
// removalProgress stays set until the batch settles, so re-entry stays blocked.
|
||||
const backToList = useCallback(() => {
|
||||
setConfirming(false)
|
||||
setConfirmCandidates([])
|
||||
}, [])
|
||||
|
||||
const settle = useCallback(() => {
|
||||
setRemovalProgress(null)
|
||||
setRemovalInFlight(false)
|
||||
setConfirming(false)
|
||||
setConfirmCandidates([])
|
||||
}, [])
|
||||
|
||||
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: a hung late settlement retains these callbacks for the renderer's
|
||||
// lifetime; capture only ids so it cannot pin the 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)
|
||||
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
|
||||
}
|
||||
})
|
||||
}, [
|
||||
clearQueuedDeleteState,
|
||||
clearWorktreeDeleteState,
|
||||
confirmCandidates,
|
||||
markWorktreesQueuedForDeletion,
|
||||
mountedRef,
|
||||
onDeselect,
|
||||
removeCandidates,
|
||||
settle
|
||||
])
|
||||
|
||||
return {
|
||||
confirming,
|
||||
confirmCandidates,
|
||||
removalProgress,
|
||||
removalInFlight,
|
||||
removalInFlightRef,
|
||||
rowFailures,
|
||||
resetRowFailures,
|
||||
resetForReopen,
|
||||
openConfirmRemove,
|
||||
confirmRemove,
|
||||
cancelConfirmRemove,
|
||||
backToList
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
arrangeWorkspaceCleanupRowsByFrozenOrder,
|
||||
createWorkspaceCleanupFrozenRowOrder
|
||||
} from './use-workspace-cleanup-row-order'
|
||||
|
||||
type Row = { worktreeId: string }
|
||||
|
||||
const row = (worktreeId: string): Row => ({ worktreeId })
|
||||
const ids = (rows: readonly Row[]): string[] => rows.map((entry) => entry.worktreeId)
|
||||
|
||||
describe('workspace cleanup streaming row order', () => {
|
||||
it('keeps existing rows in their frozen slots when streamed values would resort them', () => {
|
||||
const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b'), row('c')], 'name:asc')
|
||||
|
||||
// The live sort now says c < a < b (values changed mid-stream).
|
||||
const arranged = arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('a'), row('b')], order)
|
||||
|
||||
expect(ids(arranged)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('appends unseen rows after the frozen ones and keeps them stable on later ticks', () => {
|
||||
const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b')], 'name:asc')
|
||||
|
||||
const firstTick = arrangeWorkspaceCleanupRowsByFrozenOrder(
|
||||
[row('a'), row('d'), row('b'), row('c')],
|
||||
order
|
||||
)
|
||||
expect(ids(firstTick)).toEqual(['a', 'b', 'd', 'c'])
|
||||
|
||||
// A later tick sorting d and c differently must not swap them anymore.
|
||||
const secondTick = arrangeWorkspaceCleanupRowsByFrozenOrder(
|
||||
[row('c'), row('d'), row('b'), row('a')],
|
||||
order
|
||||
)
|
||||
expect(ids(secondTick)).toEqual(['a', 'b', 'd', 'c'])
|
||||
})
|
||||
|
||||
it('holds slots for rows a filter temporarily hides', () => {
|
||||
const order = createWorkspaceCleanupFrozenRowOrder([row('a'), row('b'), row('c')], 'name:asc')
|
||||
|
||||
expect(ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('a')], order))).toEqual([
|
||||
'a',
|
||||
'c'
|
||||
])
|
||||
// Un-hiding b restores its original slot between a and c.
|
||||
expect(
|
||||
ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('c'), row('b'), row('a')], order))
|
||||
).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('grows the frozen order from empty during a cold-start stream', () => {
|
||||
const order = createWorkspaceCleanupFrozenRowOrder([], 'name:asc')
|
||||
|
||||
expect(ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('b'), row('d')], order))).toEqual([
|
||||
'b',
|
||||
'd'
|
||||
])
|
||||
// New rows slot after the already-presented ones even when they sort earlier.
|
||||
expect(
|
||||
ids(arrangeWorkspaceCleanupRowsByFrozenOrder([row('a'), row('b'), row('c'), row('d')], order))
|
||||
).toEqual(['b', 'd', 'a', 'c'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useMemo, useRef } from 'react'
|
||||
import type { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model'
|
||||
|
||||
type OrderedRow = { worktreeId: string }
|
||||
|
||||
export type WorkspaceCleanupFrozenRowOrder = {
|
||||
sortSignature: string
|
||||
positions: Map<string, number>
|
||||
}
|
||||
|
||||
export function createWorkspaceCleanupFrozenRowOrder(
|
||||
rows: readonly OrderedRow[],
|
||||
sortSignature: string
|
||||
): WorkspaceCleanupFrozenRowOrder {
|
||||
return {
|
||||
sortSignature,
|
||||
positions: new Map(rows.map((row, index) => [row.worktreeId, index]))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows already on screen keep their slot while a refresh streams updated
|
||||
* values in; rows the order has not seen yet append after them (in the live
|
||||
* sort among themselves) and claim the next slots, so later ticks cannot
|
||||
* reshuffle them either.
|
||||
*/
|
||||
export function arrangeWorkspaceCleanupRowsByFrozenOrder<Row extends OrderedRow>(
|
||||
sortedRows: readonly Row[],
|
||||
order: WorkspaceCleanupFrozenRowOrder
|
||||
): Row[] {
|
||||
const known: Row[] = []
|
||||
const fresh: Row[] = []
|
||||
for (const row of sortedRows) {
|
||||
if (order.positions.has(row.worktreeId)) {
|
||||
known.push(row)
|
||||
} else {
|
||||
fresh.push(row)
|
||||
}
|
||||
}
|
||||
known.sort(
|
||||
(left, right) =>
|
||||
(order.positions.get(left.worktreeId) ?? 0) - (order.positions.get(right.worktreeId) ?? 0)
|
||||
)
|
||||
for (const row of fresh) {
|
||||
order.positions.set(row.worktreeId, order.positions.size)
|
||||
}
|
||||
return [...known, ...fresh]
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming-order stability for the flat list: while a scan streams, existing
|
||||
* rows must not reshuffle on every progress tick. The user's sort applies to
|
||||
* the snapshot immediately; streamed value changes land in place; one calm
|
||||
* re-sort happens when the scan settles — or at once if the user explicitly
|
||||
* changes the sort mid-scan.
|
||||
*/
|
||||
export function useWorkspaceCleanupRowOrder<Row extends OrderedRow>({
|
||||
rows,
|
||||
streaming,
|
||||
sort
|
||||
}: {
|
||||
rows: readonly Row[]
|
||||
streaming: boolean
|
||||
sort: WorkspaceCleanupSortState
|
||||
}): readonly Row[] {
|
||||
const frozenOrderRef = useRef<WorkspaceCleanupFrozenRowOrder | null>(null)
|
||||
return useMemo(() => {
|
||||
const sortSignature = `${sort.field}:${sort.direction}`
|
||||
const frozen = frozenOrderRef.current
|
||||
if (!streaming || frozen === null || frozen.sortSignature !== sortSignature) {
|
||||
// Why: settled renders (and an explicit mid-scan sort change) present the
|
||||
// live sort and freeze it as the order the next stream must hold steady.
|
||||
frozenOrderRef.current = createWorkspaceCleanupFrozenRowOrder(rows, sortSignature)
|
||||
return rows
|
||||
}
|
||||
return arrangeWorkspaceCleanupRowsByFrozenOrder(rows, frozen)
|
||||
}, [rows, sort.direction, sort.field, streaming])
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { formatWorkspaceCleanupReadyToast } from './workspace-cleanup-scan-notice'
|
||||
|
||||
export type WorkspaceCleanupScanLifecycle = {
|
||||
startWorkspaceCleanupScan: (options?: { notifyWhenReady?: boolean }) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the dialog's scan cadence: seed the list from the persisted snapshot,
|
||||
* kick one background rescan per open, and adopt — never duplicate — a scan
|
||||
* already streaming into the store when the dialog reopens mid-flight.
|
||||
*/
|
||||
export function useWorkspaceCleanupScanLifecycle({
|
||||
open,
|
||||
loading,
|
||||
removalInFlightRef,
|
||||
resetRowFailures,
|
||||
onFreshOpen
|
||||
}: {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
removalInFlightRef: { current: boolean }
|
||||
resetRowFailures: () => void
|
||||
onFreshOpen: () => void
|
||||
}): WorkspaceCleanupScanLifecycle {
|
||||
const scanWorkspaceCleanup = useAppStore((s) => s.scanWorkspaceCleanup)
|
||||
const hydrateCleanupFromCache = useAppStore((s) => s.hydrateWorkspaceCleanupFromCache)
|
||||
const hydrateSpaceFromCache = useAppStore((s) => s.hydrateWorkspaceSpaceFromCache)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const mountedRef = useMountedRef()
|
||||
const openRef = useRef(open)
|
||||
const wasOpenRef = useRef(false)
|
||||
const autoScanAttemptedForOpenRef = useRef(false)
|
||||
const latestReadyToastScanAtRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
openRef.current = open
|
||||
}, [open])
|
||||
|
||||
const startWorkspaceCleanupScan = useCallback(
|
||||
(options: { notifyWhenReady?: boolean } = {}) => {
|
||||
resetRowFailures()
|
||||
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: formatWorkspaceCleanupReadyToast(
|
||||
result.candidates.length,
|
||||
suggestedCount
|
||||
),
|
||||
action: {
|
||||
label: translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764',
|
||||
'Review'
|
||||
),
|
||||
onClick: () => openModal('workspace-cleanup')
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (mountedRef.current) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.662b8ec3f8',
|
||||
'Workspace cleanup scan failed'
|
||||
),
|
||||
{ description: err instanceof Error ? err.message : String(err) }
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
[mountedRef, openModal, resetRowFailures, scanWorkspaceCleanup]
|
||||
)
|
||||
|
||||
const hydrateThenScan = useCallback(() => {
|
||||
// Why: the cached snapshot must seed the list before the rescan starts so
|
||||
// progress reconciles into full rows instead of a cold rebuild.
|
||||
void Promise.allSettled([hydrateCleanupFromCache(), hydrateSpaceFromCache()]).then(() => {
|
||||
startWorkspaceCleanupScan({ notifyWhenReady: true })
|
||||
})
|
||||
}, [hydrateCleanupFromCache, hydrateSpaceFromCache, startWorkspaceCleanupScan])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
wasOpenRef.current = false
|
||||
autoScanAttemptedForOpenRef.current = false
|
||||
return
|
||||
}
|
||||
if (!wasOpenRef.current) {
|
||||
wasOpenRef.current = true
|
||||
autoScanAttemptedForOpenRef.current = false
|
||||
onFreshOpen()
|
||||
}
|
||||
// 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) {
|
||||
return
|
||||
}
|
||||
autoScanAttemptedForOpenRef.current = true
|
||||
if (loading) {
|
||||
// Why: reopening mid-scan adopts the stream already feeding the store;
|
||||
// starting another broad scan here would duplicate it once it settles.
|
||||
return
|
||||
}
|
||||
hydrateThenScan()
|
||||
}, [hydrateThenScan, loading, onFreshOpen, open, removalInFlightRef])
|
||||
|
||||
return { startWorkspaceCleanupScan }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import {
|
||||
hasActiveWorkspaceCleanupFilters,
|
||||
listActiveWorkspaceCleanupFacetGroups
|
||||
} from './workspace-cleanup-active-facets'
|
||||
|
||||
describe('listActiveWorkspaceCleanupFacetGroups', () => {
|
||||
it('reports nothing for the default state', () => {
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([])
|
||||
expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(false)
|
||||
})
|
||||
|
||||
it('names each edited group', () => {
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
filters.activity.idleMinDays = 45
|
||||
filters.git.states = ['dirty']
|
||||
expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual(['activity', 'git'])
|
||||
})
|
||||
|
||||
it('treats multi-select order as meaningless', () => {
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
filters.review.states = ['merged', 'closed']
|
||||
const reordered = createDefaultWorkspaceCleanupFilterState()
|
||||
reordered.review.states = ['closed', 'merged']
|
||||
expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual(
|
||||
listActiveWorkspaceCleanupFacetGroups(reordered)
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a text query as an active filter without naming a group', () => {
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
filters.query = ' alpha '
|
||||
expect(listActiveWorkspaceCleanupFacetGroups(filters)).toEqual([])
|
||||
expect(hasActiveWorkspaceCleanupFilters(filters)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
createDefaultWorkspaceCleanupFilterState,
|
||||
type WorkspaceCleanupFilterState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
|
||||
export type WorkspaceCleanupFacetGroupKey = keyof Omit<WorkspaceCleanupFilterState, 'query'>
|
||||
|
||||
const FACET_GROUP_KEYS: readonly WorkspaceCleanupFacetGroupKey[] = [
|
||||
'activity',
|
||||
'size',
|
||||
'status',
|
||||
'agent',
|
||||
'git',
|
||||
'review',
|
||||
'ticket',
|
||||
'context',
|
||||
'location',
|
||||
'safety'
|
||||
]
|
||||
|
||||
/**
|
||||
* Facet groups the user has moved off the default state. Drives the "N filters
|
||||
* on" badge, so it compares against defaults rather than against the active
|
||||
* preset — a preset is itself just a named filter state.
|
||||
*/
|
||||
export function listActiveWorkspaceCleanupFacetGroups(
|
||||
filters: WorkspaceCleanupFilterState
|
||||
): WorkspaceCleanupFacetGroupKey[] {
|
||||
const defaults = createDefaultWorkspaceCleanupFilterState()
|
||||
return FACET_GROUP_KEYS.filter((key) => !isGroupEqual(filters[key], defaults[key]))
|
||||
}
|
||||
|
||||
export function hasActiveWorkspaceCleanupFilters(filters: WorkspaceCleanupFilterState): boolean {
|
||||
return (
|
||||
filters.query.trim().length > 0 || listActiveWorkspaceCleanupFacetGroups(filters).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
/** Selection order is not meaning, so array members compare as sets. */
|
||||
function isGroupEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right))
|
||||
}
|
||||
|
||||
function normalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return [...value].map(String).sort()
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
||||
a.localeCompare(b)
|
||||
)
|
||||
return entries.map(([key, entry]) => [
|
||||
key,
|
||||
typeof entry === 'string' ? entry.trim() : normalize(entry)
|
||||
])
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupBrowseController } from './use-workspace-cleanup-browse-state'
|
||||
import type { WorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows'
|
||||
import {
|
||||
hasActiveWorkspaceCleanupFilters,
|
||||
listActiveWorkspaceCleanupFacetGroups
|
||||
} from './workspace-cleanup-active-facets'
|
||||
import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices'
|
||||
import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
|
||||
import { WorkspaceCleanupPresetChips } from './workspace-cleanup-preset-chips'
|
||||
import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header'
|
||||
|
||||
export function WorkspaceCleanupBrowseToolbar({
|
||||
browse,
|
||||
facetRows,
|
||||
selectedCount,
|
||||
spaceScanning,
|
||||
gitPendingCount,
|
||||
gitCheckedTotal,
|
||||
onRunSpaceScan,
|
||||
onToggleSelectAll
|
||||
}: {
|
||||
browse: WorkspaceCleanupBrowseController
|
||||
facetRows: WorkspaceCleanupFacetRows
|
||||
selectedCount: number
|
||||
spaceScanning: boolean
|
||||
gitPendingCount: number
|
||||
gitCheckedTotal: number
|
||||
onRunSpaceScan: () => void
|
||||
onToggleSelectAll: (selectAll: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const activeFilters = hasActiveWorkspaceCleanupFilters(browse.filters)
|
||||
return (
|
||||
<>
|
||||
<WorkspaceCleanupPresetChips
|
||||
presets={browse.presets}
|
||||
matchedPresetId={browse.matchedPresetId}
|
||||
requestedPresetId={browse.browse.activePresetId}
|
||||
hasActiveFilters={activeFilters}
|
||||
onApplyPreset={browse.applyPreset}
|
||||
onClearFilters={browse.clearFilters}
|
||||
/>
|
||||
<WorkspaceCleanupFilterBar
|
||||
facetProps={{
|
||||
filters: browse.filters,
|
||||
counts: facetRows.facetCounts,
|
||||
totalCount: facetRows.totalCount,
|
||||
options: facetRows.options,
|
||||
onPatch: browse.patchFilters
|
||||
}}
|
||||
activeFacetGroupCount={listActiveWorkspaceCleanupFacetGroups(browse.filters).length}
|
||||
matchedCount={facetRows.matchedCount}
|
||||
hasActiveFilters={activeFilters}
|
||||
sizeScan={{
|
||||
measuredCount: facetRows.measuredSizeCount,
|
||||
scanning: spaceScanning,
|
||||
onRun: onRunSpaceScan
|
||||
}}
|
||||
gitEvidence={{ pendingCount: gitPendingCount, totalCount: gitCheckedTotal }}
|
||||
onQueryChange={(query) => browse.patchFilters('query', query)}
|
||||
onClearFilters={browse.clearFilters}
|
||||
/>
|
||||
<WorkspaceCleanupSortHeader
|
||||
sort={browse.sort}
|
||||
selectableCount={facetRows.selectableWorktreeIds.length}
|
||||
selectedCount={selectedCount}
|
||||
onToggleSortField={browse.toggleSortField}
|
||||
onSetSort={browse.setSort}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
/>
|
||||
{browse.sort.field === 'size' && facetRows.measuredSizeCount === 0 ? (
|
||||
<WorkspaceCleanupEmptyState
|
||||
title={translate(
|
||||
'components.workspace.cleanup.browse.noSizes',
|
||||
'No workspace sizes measured yet.'
|
||||
)}
|
||||
description={translate(
|
||||
'components.workspace.cleanup.browse.noSizesDescription',
|
||||
'Sizes come from the disk-space scan. Run it to sort and filter by size.'
|
||||
)}
|
||||
actionLabel={translate(
|
||||
'components.workspace.cleanup.browse.measureSizes',
|
||||
'Measure sizes'
|
||||
)}
|
||||
onAction={onRunSpaceScan}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
|
||||
/** Any row shape the flat list renders — raw candidates or enriched facet rows. */
|
||||
type WorkspaceCleanupListRow = { worktreeId: string }
|
||||
|
||||
// Why: below this count plain rows keep the pre-virtualization DOM (natural
|
||||
// flow, no absolute positioning), so the common few-worktrees case is
|
||||
@@ -19,7 +21,7 @@ const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8
|
||||
* no scroll-margin bookkeeping is needed. Lists shorter than
|
||||
* WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS render plainly.
|
||||
*/
|
||||
export function WorkspaceCleanupCandidateList({
|
||||
export function WorkspaceCleanupCandidateList<Row extends WorkspaceCleanupListRow>({
|
||||
rows,
|
||||
renderRow,
|
||||
// Why: a state-held element, not a ref — the ScrollArea viewport is not
|
||||
@@ -27,8 +29,8 @@ export function WorkspaceCleanupCandidateList({
|
||||
// virtualizer unobserved until some unrelated re-render.
|
||||
scrollElement
|
||||
}: {
|
||||
rows: readonly WorkspaceCleanupCandidate[]
|
||||
renderRow: (candidate: WorkspaceCleanupCandidate, index: number) => React.ReactNode
|
||||
rows: readonly Row[]
|
||||
renderRow: (row: Row, index: number) => React.ReactNode
|
||||
scrollElement: HTMLDivElement | null
|
||||
}): React.JSX.Element {
|
||||
const virtualize = rows.length >= WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FileWarning,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Search,
|
||||
SquareTerminal,
|
||||
@@ -48,9 +49,13 @@ type CandidateRowProps = {
|
||||
deletionPhase?: WorkspaceCleanupDeletionPhase
|
||||
expanded: boolean
|
||||
failure?: string
|
||||
/** A focused git re-scan is in flight, so "Not checked" is provisional. */
|
||||
gitEvidencePending?: boolean
|
||||
last: boolean
|
||||
lastActivityLabel: string
|
||||
removing?: boolean
|
||||
/** Joined from the workspace-space scan; null when that scan has not run. */
|
||||
sizeLabel?: string | null
|
||||
reviewInfo: WorkspaceCleanupReviewInfo
|
||||
selected: boolean
|
||||
onIgnore: (candidate: WorkspaceCleanupCandidate) => void
|
||||
@@ -106,9 +111,11 @@ export const CandidateRow = React.memo(function CandidateRow({
|
||||
deletionPhase,
|
||||
expanded,
|
||||
failure,
|
||||
gitEvidencePending = false,
|
||||
last,
|
||||
lastActivityLabel,
|
||||
removing = false,
|
||||
sizeLabel = null,
|
||||
reviewInfo,
|
||||
selected,
|
||||
onIgnore,
|
||||
@@ -193,7 +200,28 @@ export const CandidateRow = React.memo(function CandidateRow({
|
||||
)} ${lastActivityLabel}`}
|
||||
value={formatCompactActivityLabel(lastActivityLabel)}
|
||||
/>
|
||||
{dirtyLabel && showGitMetadataChip ? (
|
||||
{sizeLabel ? (
|
||||
<MetadataIconChip
|
||||
icon={HardDrive}
|
||||
label={translate(
|
||||
'components.workspace.cleanup.browse.sizeOnDisk',
|
||||
'Size on disk: {{value0}}',
|
||||
{ value0: sizeLabel }
|
||||
)}
|
||||
value={sizeLabel}
|
||||
/>
|
||||
) : null}
|
||||
{gitEvidencePending ? (
|
||||
<span
|
||||
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',
|
||||
'Checking git status'
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-3 animate-spin" aria-hidden="true" />
|
||||
</span>
|
||||
) : dirtyLabel && showGitMetadataChip ? (
|
||||
<MetadataIconChip icon={FileWarning} label={dirtyLabel} tone="destructive" />
|
||||
) : showGitMetadataChip ? (
|
||||
<MetadataIconChip
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, Loader2, Trash2, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal'
|
||||
import {
|
||||
getCandidateStatus,
|
||||
getContextPillLabel,
|
||||
getDirtyGitLabel,
|
||||
getReviewPillTone,
|
||||
shouldShowGitMetadataChip
|
||||
} from './workspace-cleanup-candidate-row-data'
|
||||
import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation'
|
||||
import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time'
|
||||
import { StatusPill } from './workspace-cleanup-status-pill'
|
||||
|
||||
const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = {
|
||||
hasReview: false,
|
||||
label: null,
|
||||
state: null,
|
||||
provider: null,
|
||||
title: null
|
||||
}
|
||||
|
||||
export function WorkspaceCleanupConfirmRemove({
|
||||
candidates,
|
||||
reviewInfoByWorktreeId,
|
||||
progress,
|
||||
onBack,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
candidates: WorkspaceCleanupCandidate[]
|
||||
reviewInfoByWorktreeId: ReadonlyMap<string, WorkspaceCleanupReviewInfo>
|
||||
progress: WorkspaceCleanupRemovalProgress | null
|
||||
onBack: () => void
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}): React.JSX.Element {
|
||||
const count = candidates.length
|
||||
const deleting = progress !== null
|
||||
const progressValue = progress
|
||||
? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100))
|
||||
: 0
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="border-b border-border px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-destructive/25 bg-destructive/10 text-destructive">
|
||||
{deleting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<AlertTriangle className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-base">
|
||||
{deleting
|
||||
? translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount',
|
||||
'Deleting workspaces: {{value0}}',
|
||||
{ value0: count }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount',
|
||||
'Delete workspaces: {{value0}}?',
|
||||
{ value0: count }
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1.5 text-xs leading-5">
|
||||
{deleting
|
||||
? translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1d3503357d',
|
||||
'You can close this and come back while deletion continues.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.38ca0b1400',
|
||||
"This permanently deletes their local files. You can't undo this."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.74f6c16279',
|
||||
'Back'
|
||||
)}
|
||||
onClick={onBack}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{progress ? (
|
||||
<div className="border-b border-border bg-muted/25 px-5 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
<span className="font-medium text-foreground">
|
||||
{formatWorkspaceCleanupRemovalProgress(progress)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressValue} className="mt-2 h-1.5" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-2.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount',
|
||||
'Selected for deletion: {{value0}}',
|
||||
{ value0: count }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{candidates.map((candidate, index) => (
|
||||
<ConfirmRemoveRow
|
||||
key={candidate.worktreeId}
|
||||
candidate={candidate}
|
||||
reviewInfo={reviewInfoByWorktreeId.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO}
|
||||
last={index === candidates.length - 1}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<DialogFooter className="border-t border-border px-5 py-3">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
{deleting
|
||||
? translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.191f0bc98e',
|
||||
'Close'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b6bae1eed1',
|
||||
'Cancel'
|
||||
)}
|
||||
</Button>
|
||||
{!deleting ? (
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={count === 0}>
|
||||
<Trash2 className="size-4" />
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteButtonCount',
|
||||
'Delete {{value0}}',
|
||||
{ value0: count }
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfirmRemoveRow({
|
||||
candidate,
|
||||
reviewInfo,
|
||||
last
|
||||
}: {
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
reviewInfo: WorkspaceCleanupReviewInfo
|
||||
last: boolean
|
||||
}): React.JSX.Element {
|
||||
const dirtyLabel = getDirtyGitLabel(candidate)
|
||||
const branchDiffersFromName = candidate.branch !== candidate.displayName
|
||||
const contextPillLabel = getContextPillLabel(candidate)
|
||||
const showGitMetadataChip = shouldShowGitMetadataChip(candidate)
|
||||
const status = getCandidateStatus(candidate)
|
||||
return (
|
||||
<div className={cn('border-b border-border/60 px-5 py-2.5', last && 'border-b-0')}>
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="min-w-0 truncate text-sm font-medium">{candidate.displayName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc',
|
||||
'Last active'
|
||||
)}{' '}
|
||||
{formatWorkspaceCleanupRelativeTime(candidate.lastActivityAt)}
|
||||
</span>
|
||||
<StatusPill tone={status.tone}>{status.label}</StatusPill>
|
||||
{reviewInfo.label ? (
|
||||
<StatusPill tone={getReviewPillTone(reviewInfo)}>{reviewInfo.label}</StatusPill>
|
||||
) : null}
|
||||
{contextPillLabel ? <StatusPill>{contextPillLabel}</StatusPill> : null}
|
||||
{dirtyLabel && showGitMetadataChip ? (
|
||||
<StatusPill tone="destructive">{dirtyLabel}</StatusPill>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap items-baseline gap-x-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{candidate.repoName}</span>
|
||||
{branchDiffersFromName ? (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="min-w-0 truncate font-mono">{candidate.branch}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 min-w-0 truncate font-mono text-[11px] text-muted-foreground/80">
|
||||
{candidate.path}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatWorkspaceCleanupRemovalProgress(progress: WorkspaceCleanupRemovalProgress): string {
|
||||
const deletedText = translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4c2990886e',
|
||||
'{{value0}}/{{value1}} deleted',
|
||||
{ value0: progress.removedCount, value1: progress.totalCount }
|
||||
)
|
||||
if (progress.failedCount === 0) {
|
||||
return deletedText
|
||||
}
|
||||
return translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.86ba852118',
|
||||
'{{value0}}, {{value1}} failed',
|
||||
{ value0: deletedText, value1: progress.failedCount }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react'
|
||||
import { RefreshCcw, Trash2, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup'
|
||||
import { WorkspaceCleanupFreshness } from './workspace-cleanup-freshness'
|
||||
|
||||
export function WorkspaceCleanupDialogHeader({
|
||||
selectedCount,
|
||||
deleteDisabled,
|
||||
loading,
|
||||
scannedAt,
|
||||
scanProgress,
|
||||
onDeleteSelected,
|
||||
onRefresh,
|
||||
onClose
|
||||
}: {
|
||||
selectedCount: number
|
||||
deleteDisabled: boolean
|
||||
loading: boolean
|
||||
scannedAt: number | null
|
||||
scanProgress: WorkspaceCleanupScanProgress | null
|
||||
onDeleteSelected: () => void
|
||||
onRefresh: () => void
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const refreshLabel = translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7ae2ad30f4',
|
||||
'Refresh'
|
||||
)
|
||||
return (
|
||||
<DialogHeader className="border-b border-border px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<DialogTitle className="min-w-0 text-base">
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b2c1331844',
|
||||
'Delete Inactive Workspaces'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<WorkspaceCleanupFreshness
|
||||
scannedAt={scannedAt}
|
||||
refreshing={loading}
|
||||
progress={scanProgress}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{selectedCount}{' '}
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ac5ba84cc1',
|
||||
'selected'
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDeleteSelected}
|
||||
disabled={deleteDisabled}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.b771c92598',
|
||||
'Delete selected'
|
||||
)}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={refreshLabel}
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCcw className={cn('size-3.5', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{refreshLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.191f0bc98e',
|
||||
'Close'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react'
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup'
|
||||
import { formatWorkspaceCleanupScanProgress } from './workspace-cleanup-scan-notice'
|
||||
|
||||
export function WorkspaceCleanupInitialScanBanner({
|
||||
progress
|
||||
}: {
|
||||
progress: WorkspaceCleanupScanProgress | null
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-start gap-2 border-b border-border bg-muted/25 px-5 py-3">
|
||||
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-foreground">
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968',
|
||||
'Checking inactive workspaces'
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.47123d0108',
|
||||
'Scanning inactive workspaces. You can close this and come back.'
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-medium text-muted-foreground">
|
||||
{formatWorkspaceCleanupScanProgress(progress)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceCleanupNotice({
|
||||
tone = 'muted',
|
||||
message
|
||||
}: {
|
||||
tone?: 'muted' | 'destructive'
|
||||
message: string
|
||||
}): React.JSX.Element {
|
||||
if (tone === 'destructive') {
|
||||
return (
|
||||
<div className="border-b border-destructive/30 bg-destructive/10 px-5 py-2 text-xs text-destructive">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-start gap-2 border-b border-border bg-muted/25 px-5 py-2 text-xs text-muted-foreground">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceCleanupEmptyState({
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex min-h-48 flex-col items-center justify-center gap-2 rounded-lg border border-border bg-muted/20 px-6 text-center text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{title}</span>
|
||||
{description ? <span className="text-xs">{description}</span> : null}
|
||||
{actionLabel && onAction ? (
|
||||
<Button variant="outline" size="sm" className="mt-1" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceCleanupSkeletonRows(): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-24 animate-pulse rounded-lg border border-border bg-muted/35"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { ReactNode } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import {
|
||||
createDefaultWorkspaceCleanupFilterState,
|
||||
type WorkspaceCleanupFilterState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets'
|
||||
import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets'
|
||||
import type {
|
||||
WorkspaceCleanupFacetCounts,
|
||||
WorkspaceCleanupFacetGroupProps,
|
||||
WorkspaceCleanupFacetOptions
|
||||
} from './workspace-cleanup-facet-panel-model'
|
||||
|
||||
/** `Mock<T>` erases T's type parameter, so keep the generic call signature and add the spy API. */
|
||||
type PatchMock = WorkspaceCleanupFacetGroupProps['onPatch'] & MockInstance
|
||||
|
||||
function createPatchMock(): PatchMock {
|
||||
return vi.fn()
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
const COUNTS: WorkspaceCleanupFacetCounts = {
|
||||
activity: 3,
|
||||
size: 4,
|
||||
status: 8,
|
||||
agent: 8,
|
||||
git: 2,
|
||||
review: 5,
|
||||
ticket: 8,
|
||||
context: 6,
|
||||
location: 8,
|
||||
safety: 7
|
||||
}
|
||||
|
||||
const OPTIONS: WorkspaceCleanupFacetOptions = {
|
||||
workspaceStatuses: [{ id: 'in-progress', label: 'In progress' }],
|
||||
hostIds: ['local', 'ssh:box'],
|
||||
repos: [
|
||||
{ id: 'repo-1', label: 'Alpha' },
|
||||
{ id: 'repo-2', label: 'Beta' }
|
||||
],
|
||||
reviewProviders: ['github', 'gitlab']
|
||||
}
|
||||
|
||||
function render(node: ReactNode): void {
|
||||
act(() => root?.render(node))
|
||||
}
|
||||
|
||||
function control(label: string): HTMLElement | null {
|
||||
return container?.querySelector<HTMLElement>(`[aria-label="${label}"]`) ?? null
|
||||
}
|
||||
|
||||
/** React tracks the controlled value, so a bare `input.value =` is swallowed. */
|
||||
function typeInto(input: HTMLInputElement | null, value: string): void {
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|
||||
setter?.call(input, value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
function renderFacets(filters: WorkspaceCleanupFilterState, onPatch: PatchMock): void {
|
||||
const props = { filters, counts: COUNTS, totalCount: 8, options: OPTIONS, onPatch }
|
||||
render(
|
||||
<>
|
||||
<WorkspaceCleanupLifecycleFacets {...props} />
|
||||
<WorkspaceCleanupGitReviewFacets {...props} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('workspace cleanup facet controls', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
it('shows a per-facet match count for every group', () => {
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock())
|
||||
|
||||
const counts = [...(container?.querySelectorAll('[data-facet-count]') ?? [])].map(
|
||||
(node) => node.textContent
|
||||
)
|
||||
expect(counts).toHaveLength(10)
|
||||
expect(counts).toContain('2/8')
|
||||
expect(counts).toContain('3/8')
|
||||
})
|
||||
|
||||
it('takes a user-chosen idle threshold in days rather than a fixed enum', () => {
|
||||
const onPatch = createPatchMock()
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch)
|
||||
|
||||
const input = control('Idle for at least') as HTMLInputElement | null
|
||||
expect(input?.type).toBe('number')
|
||||
act(() => typeInto(input, '17'))
|
||||
expect(onPatch).toHaveBeenCalledWith('activity', { idleMinDays: 17 })
|
||||
})
|
||||
|
||||
it('multi-selects git states', () => {
|
||||
const onPatch = createPatchMock()
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
filters.git.states = ['dirty']
|
||||
renderFacets(filters, onPatch)
|
||||
|
||||
expect(control('Uncommitted changes')?.getAttribute('aria-checked')).toBe('true')
|
||||
act(() => control('Unpushed commits')?.click())
|
||||
expect(onPatch).toHaveBeenCalledWith('git', { states: ['dirty', 'unpushed'] })
|
||||
})
|
||||
|
||||
it('offers the hosts and providers discovered in the fleet, not a hardcoded list', () => {
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock())
|
||||
|
||||
expect(control('GitLab')).not.toBeNull()
|
||||
expect(container?.textContent).toContain('Alpha')
|
||||
expect(container?.textContent).toContain('In progress')
|
||||
})
|
||||
|
||||
it('cycles a tri-state safety facet', () => {
|
||||
const onPatch = createPatchMock()
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch)
|
||||
|
||||
act(() => container?.querySelector<HTMLElement>('[aria-label="Ignored: Only"]')?.click())
|
||||
expect(onPatch).toHaveBeenCalledWith('safety', { dismissed: 'only' })
|
||||
})
|
||||
|
||||
it('toggles the unsized escape hatch for the opt-in space scan', () => {
|
||||
const onPatch = createPatchMock()
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch)
|
||||
|
||||
const checkbox = control('Include unmeasured workspaces')
|
||||
expect(checkbox?.getAttribute('data-state')).toBe('checked')
|
||||
act(() => checkbox?.click())
|
||||
expect(onPatch).toHaveBeenCalledWith('size', { includeUnsized: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import React from 'react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** One facet group inside the filter panel, with its own match count. */
|
||||
export function FacetSection({
|
||||
title,
|
||||
matchCount,
|
||||
totalCount,
|
||||
children
|
||||
}: {
|
||||
title: string
|
||||
matchCount: number
|
||||
totalCount: number
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<section className="border-b border-border px-3 py-2.5 last:border-b-0">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{title}
|
||||
</h4>
|
||||
<span
|
||||
className="shrink-0 tabular-nums text-[11px] text-muted-foreground"
|
||||
data-facet-count={title}
|
||||
>
|
||||
{matchCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Multi-select: empty selection leaves the facet unconstrained. */
|
||||
export function FacetToggleList<T extends string>({
|
||||
label,
|
||||
values,
|
||||
selected,
|
||||
getLabel,
|
||||
onChange
|
||||
}: {
|
||||
label?: string
|
||||
values: readonly T[]
|
||||
selected: readonly T[]
|
||||
getLabel: (value: T) => string
|
||||
onChange: (next: T[]) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<FacetField label={label}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{values.map((value) => {
|
||||
const active = selected.includes(value)
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={active}
|
||||
aria-label={getLabel(value)}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
active ? selected.filter((entry) => entry !== value) : [...selected, value]
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'inline-flex h-6 items-center rounded-full border border-border px-2 text-[11px] font-medium text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
active && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{getLabel(value)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FacetField>
|
||||
)
|
||||
}
|
||||
|
||||
/** Single-choice segmented control for tri-state / presence / enum facets. */
|
||||
export function FacetChoice<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
getLabel,
|
||||
onChange
|
||||
}: {
|
||||
label: string
|
||||
value: T
|
||||
options: readonly T[]
|
||||
getLabel?: (value: T) => string
|
||||
onChange: (next: T) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<FacetField label={label}>
|
||||
<div className="flex gap-1" role="radiogroup" aria-label={label}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
role="radio"
|
||||
data-facet-value={option}
|
||||
aria-checked={value === option}
|
||||
aria-label={`${label}: ${getLabel ? getLabel(option) : option}`}
|
||||
onClick={() => onChange(option)}
|
||||
className={cn(
|
||||
'inline-flex h-6 items-center rounded-md border border-border px-2 text-[11px] font-medium text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
value === option && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{getLabel ? getLabel(option) : option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FacetField>
|
||||
)
|
||||
}
|
||||
|
||||
/** Free-form numeric threshold — deliberately not a fixed 30/90 enum. */
|
||||
export function FacetNumberField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
suffix,
|
||||
onChange
|
||||
}: {
|
||||
label: string
|
||||
value: number | null
|
||||
placeholder?: string
|
||||
suffix?: string
|
||||
onChange: (next: number | null) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<FacetField label={label}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
aria-label={label}
|
||||
value={value === null ? '' : String(value)}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(parseFacetNumber(event.target.value))}
|
||||
className="h-7 w-24 text-xs"
|
||||
/>
|
||||
{suffix ? <span className="text-[11px] text-muted-foreground">{suffix}</span> : null}
|
||||
</div>
|
||||
</FacetField>
|
||||
)
|
||||
}
|
||||
|
||||
export function FacetTextField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onChange
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
onChange: (next: string) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<FacetField label={label}>
|
||||
<Input
|
||||
aria-label={label}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</FacetField>
|
||||
)
|
||||
}
|
||||
|
||||
export function FacetCheckbox({
|
||||
label,
|
||||
checked,
|
||||
onChange
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (next: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const id = `workspace-cleanup-facet-${label.replace(/\W+/g, '-').toLowerCase()}`
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
onCheckedChange={(next) => onChange(next === true)}
|
||||
/>
|
||||
<Label htmlFor={id} className="cursor-pointer text-xs font-normal text-foreground">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FacetField({
|
||||
label,
|
||||
children
|
||||
}: {
|
||||
label?: string
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
if (!label) {
|
||||
return <>{children}</>
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function parseFacetNumber(raw: string): number | null {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed === '') {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildWorkspaceCleanupFacetList,
|
||||
buildWorkspaceCleanupSizeIndex,
|
||||
buildWorkspaceCleanupWorktreeIndex
|
||||
} from './workspace-cleanup-facets'
|
||||
import {
|
||||
DAY,
|
||||
FACET_NOW,
|
||||
makeFacetCandidate,
|
||||
makeFacets,
|
||||
makeNamedFacets
|
||||
} from './workspace-cleanup-facet.test.fixture'
|
||||
import {
|
||||
createDefaultWorkspaceCleanupFilterState,
|
||||
type WorkspaceCleanupFilterState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import {
|
||||
countWorkspaceCleanupFacetMatches,
|
||||
filterWorkspaceCleanupFacets,
|
||||
matchesWorkspaceCleanupFilters,
|
||||
queryWorkspaceCleanupCandidates
|
||||
} from './workspace-cleanup-query'
|
||||
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
|
||||
|
||||
function filters(patch: (state: WorkspaceCleanupFilterState) => void): WorkspaceCleanupFilterState {
|
||||
const state = createDefaultWorkspaceCleanupFilterState()
|
||||
patch(state)
|
||||
return state
|
||||
}
|
||||
|
||||
const ANY = filters((state) => {
|
||||
state.safety.dismissed = 'any'
|
||||
})
|
||||
|
||||
describe('facet building', () => {
|
||||
it('strips the ref prefix and falls back to local host', () => {
|
||||
const facets = makeFacets({ worktree: { branch: 'refs/heads/feature/x' } })
|
||||
expect(facets.branch).toBe('feature/x')
|
||||
expect(facets.hostId).toBe('local')
|
||||
})
|
||||
|
||||
it('treats a missing size entry as unsized rather than zero', () => {
|
||||
expect(makeFacets().sizeBytes).toBeNull()
|
||||
expect(makeFacets({ sizeBytes: 0 }).sizeBytes).toBe(0)
|
||||
})
|
||||
|
||||
it('treats a missing visit stamp as never opened', () => {
|
||||
expect(makeFacets().lastVisitedAt).toBeNull()
|
||||
expect(makeFacets({ lastVisitedAt: FACET_NOW }).lastVisitedAt).toBe(FACET_NOW)
|
||||
})
|
||||
|
||||
it('only trusts sizes from an ok space scan', () => {
|
||||
const scanned = [
|
||||
{ worktreeId: 'a', status: 'ok', sizeBytes: 10 },
|
||||
{ worktreeId: 'b', status: 'permission-denied', sizeBytes: 99 }
|
||||
] as WorkspaceSpaceWorktree[]
|
||||
const index = buildWorkspaceCleanupSizeIndex(scanned)
|
||||
expect(index.get('a')).toBe(10)
|
||||
expect(index.has('b')).toBe(false)
|
||||
})
|
||||
|
||||
it('indexes worktrees across repos by id', () => {
|
||||
const index = buildWorkspaceCleanupWorktreeIndex({
|
||||
'repo-1': [{ id: 'w1' }],
|
||||
'repo-2': [{ id: 'w2' }]
|
||||
})
|
||||
expect([...index.keys()]).toEqual(['w1', 'w2'])
|
||||
})
|
||||
|
||||
it('counts a workspace with nothing attached as completely empty', () => {
|
||||
expect(makeFacets().isCompletelyEmpty).toBe(true)
|
||||
expect(makeFacets({ worktree: { comment: 'keep' } }).isCompletelyEmpty).toBe(false)
|
||||
expect(makeFacets({ review: {} }).isCompletelyEmpty).toBe(false)
|
||||
expect(makeFacets({ worktree: { linkedIssue: 12 } }).isCompletelyEmpty).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('query search', () => {
|
||||
it('matches branch, host, status, provider, and blockers', () => {
|
||||
const facets = makeFacets({
|
||||
candidate: { blockers: ['live-agent'] },
|
||||
worktree: {
|
||||
branch: 'refs/heads/fix/login',
|
||||
hostId: 'ssh:builder',
|
||||
workspaceStatus: 'in-review'
|
||||
},
|
||||
review: { provider: 'gitlab', label: 'MR #7' }
|
||||
})
|
||||
for (const term of ['fix/login', 'ssh:builder', 'in-review', 'gitlab', 'live-agent']) {
|
||||
expect(matchesWorkspaceCleanupFilters(facets, { ...ANY, query: term }, FACET_NOW)).toBe(true)
|
||||
}
|
||||
expect(matchesWorkspaceCleanupFilters(facets, { ...ANY, query: 'nope' }, FACET_NOW)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('activity filter', () => {
|
||||
it('uses the user-chosen day threshold against the chosen signal', () => {
|
||||
const recentlyVisited = makeFacets({ lastVisitedAt: FACET_NOW - 5 * DAY })
|
||||
const staleVisit = makeFacets({ lastVisitedAt: FACET_NOW - 45 * DAY })
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.activity.idleSignal = 'last-visited'
|
||||
s.activity.idleMinDays = 30
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(recentlyVisited, state, FACET_NOW)).toBe(false)
|
||||
expect(matchesWorkspaceCleanupFilters(staleVisit, state, FACET_NOW)).toBe(true)
|
||||
})
|
||||
|
||||
it('reads a missing signal as maximally idle', () => {
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.activity.idleMinDays = 365
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true)
|
||||
})
|
||||
|
||||
it('separates background activity from a real visit', () => {
|
||||
const busyButUnopened = makeFacets({ candidate: { lastActivityAt: FACET_NOW } })
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.activity.neverVisited = true
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(busyButUnopened, state, FACET_NOW)).toBe(true)
|
||||
expect(
|
||||
matchesWorkspaceCleanupFilters(makeFacets({ lastVisitedAt: FACET_NOW }), state, FACET_NOW)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('size filter', () => {
|
||||
it('keeps unsized rows unless the user opts out', () => {
|
||||
const unsized = makeFacets()
|
||||
expect(matchesWorkspaceCleanupFilters(unsized, ANY, FACET_NOW)).toBe(true)
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.size.includeUnsized = false
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(unsized, state, FACET_NOW)).toBe(false)
|
||||
})
|
||||
|
||||
it('applies min and max byte bounds', () => {
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.size.minBytes = 100
|
||||
s.size.maxBytes = 200
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 150 }), state, FACET_NOW)).toBe(
|
||||
true
|
||||
)
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 99 }), state, FACET_NOW)).toBe(
|
||||
false
|
||||
)
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets({ sizeBytes: 201 }), state, FACET_NOW)).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('status filter', () => {
|
||||
it('matches open workspace-status strings and statusless rows separately', () => {
|
||||
const inReview = makeFacets({ worktree: { workspaceStatus: 'in-review' } })
|
||||
const statusless = makeFacets()
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.status.workspaceStatuses = ['in-review']
|
||||
s.status.matchStatusless = false
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(inReview, state, FACET_NOW)).toBe(true)
|
||||
expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(false)
|
||||
state.status.matchStatusless = true
|
||||
expect(matchesWorkspaceCleanupFilters(statusless, state, FACET_NOW)).toBe(true)
|
||||
})
|
||||
|
||||
it('applies tri-state flags', () => {
|
||||
const archived = makeFacets({ worktree: { isArchived: true } })
|
||||
const only = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.status.archived = 'only'
|
||||
})
|
||||
const exclude = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.status.archived = 'exclude'
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(archived, only, FACET_NOW)).toBe(true)
|
||||
expect(matchesWorkspaceCleanupFilters(archived, exclude, FACET_NOW)).toBe(false)
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), exclude, FACET_NOW)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent, git, review, ticket, context, and location filters', () => {
|
||||
it('filters by live agent rollup and retained done agents', () => {
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.agent.states = ['permission']
|
||||
})
|
||||
expect(
|
||||
matchesWorkspaceCleanupFilters(makeFacets({ agentStatus: 'permission' }), state, FACET_NOW)
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchesWorkspaceCleanupFilters(makeFacets({ agentStatus: 'working' }), state, FACET_NOW)
|
||||
).toBe(false)
|
||||
expect(makeFacets().agentState).toBe('idle')
|
||||
|
||||
const retained = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.agent.retainedDoneAgents = 'only'
|
||||
})
|
||||
const withRetained = makeFacets({
|
||||
candidate: {
|
||||
localContext: {
|
||||
...makeFacetCandidate().localContext,
|
||||
retainedDoneAgentCount: 2
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(withRetained, retained, FACET_NOW)).toBe(true)
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), retained, FACET_NOW)).toBe(false)
|
||||
})
|
||||
|
||||
it('filters by git state, ahead counts, branch text, prunable and locked', () => {
|
||||
const unpushed = makeFacets({
|
||||
candidate: { git: { clean: true, upstreamAhead: 3, upstreamBehind: 1, checkedAt: 1 } }
|
||||
})
|
||||
expect(unpushed.gitState).toBe('unpushed')
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.git.states = ['unpushed']
|
||||
s.git.minAhead = 2
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(unpushed, state, FACET_NOW)).toBe(true)
|
||||
state.git.minAhead = 4
|
||||
expect(matchesWorkspaceCleanupFilters(unpushed, state, FACET_NOW)).toBe(false)
|
||||
|
||||
const branchState = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.git.branchQuery = 'ALPH'
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), branchState, FACET_NOW)).toBe(true)
|
||||
|
||||
const prunable = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.git.prunable = 'only'
|
||||
})
|
||||
expect(
|
||||
matchesWorkspaceCleanupFilters(
|
||||
makeFacets({ worktree: { prunable: true } }),
|
||||
prunable,
|
||||
FACET_NOW
|
||||
)
|
||||
).toBe(true)
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), prunable, FACET_NOW)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps review filtering provider-general and treats draft as a state', () => {
|
||||
const draftMr = makeFacets({ review: { provider: 'gitlab', state: 'draft', label: 'MR #7' } })
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.review.presence = 'some'
|
||||
s.review.states = ['draft']
|
||||
s.review.providers = ['gitlab', 'bitbucket']
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(draftMr, state, FACET_NOW)).toBe(true)
|
||||
state.review.providers = ['github']
|
||||
expect(matchesWorkspaceCleanupFilters(draftMr, state, FACET_NOW)).toBe(false)
|
||||
|
||||
const none = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.review.presence = 'none'
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), none, FACET_NOW)).toBe(true)
|
||||
expect(matchesWorkspaceCleanupFilters(draftMr, none, FACET_NOW)).toBe(false)
|
||||
})
|
||||
|
||||
it('filters by ticket source', () => {
|
||||
const linear = makeFacets({ worktree: { linkedLinearIssue: 'STA-1' } })
|
||||
expect(linear.ticketSources).toEqual(['linear'])
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.ticket.presence = 'some'
|
||||
s.ticket.sources = ['linear']
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(linear, state, FACET_NOW)).toBe(true)
|
||||
expect(
|
||||
matchesWorkspaceCleanupFilters(makeFacets({ worktree: { linkedIssue: 9 } }), state, FACET_NOW)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('filters by local context and completely-empty', () => {
|
||||
const busy = makeFacets({
|
||||
candidate: {
|
||||
localContext: { ...makeFacetCandidate().localContext, terminalTabCount: 2 }
|
||||
}
|
||||
})
|
||||
const hasContext = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.context.presence = 'some'
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(busy, hasContext, FACET_NOW)).toBe(true)
|
||||
const empty = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.context.completelyEmpty = true
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(busy, empty, FACET_NOW)).toBe(false)
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), empty, FACET_NOW)).toBe(true)
|
||||
})
|
||||
|
||||
it('filters by execution host, repo, and path prefix', () => {
|
||||
const remote = makeFacets({ worktree: { hostId: 'ssh:builder' } })
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.location.hostIds = ['ssh:builder']
|
||||
s.location.repoIds = ['repo-1']
|
||||
s.location.pathPrefix = '/repo/'
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(remote, state, FACET_NOW)).toBe(true)
|
||||
state.location.pathPrefix = '/other/'
|
||||
expect(matchesWorkspaceCleanupFilters(remote, state, FACET_NOW)).toBe(false)
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('safety filter', () => {
|
||||
it('supports any-of and none-of blocker selection', () => {
|
||||
const live = makeFacets({
|
||||
candidate: { blockers: ['live-agent', 'pinned'], tier: 'protected' }
|
||||
})
|
||||
const anyOf = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.safety.blockers = ['live-agent']
|
||||
s.safety.blockerMode = 'any-of'
|
||||
})
|
||||
const noneOf = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.safety.blockers = ['live-agent']
|
||||
s.safety.blockerMode = 'none-of'
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(live, anyOf, FACET_NOW)).toBe(true)
|
||||
expect(matchesWorkspaceCleanupFilters(live, noneOf, FACET_NOW)).toBe(false)
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), noneOf, FACET_NOW)).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes dismissed rows by default and surfaces them on demand', () => {
|
||||
const dismissed = makeFacets({ dismissed: true })
|
||||
expect(
|
||||
matchesWorkspaceCleanupFilters(
|
||||
dismissed,
|
||||
createDefaultWorkspaceCleanupFilterState(),
|
||||
FACET_NOW
|
||||
)
|
||||
).toBe(false)
|
||||
const only = filters((s) => {
|
||||
s.safety.dismissed = 'only'
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(dismissed, only, FACET_NOW)).toBe(true)
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), only, FACET_NOW)).toBe(false)
|
||||
})
|
||||
|
||||
it('restricts to rows the policy would actually let the user delete', () => {
|
||||
const blocked = makeFacets({ candidate: { blockers: ['dirty-files'], tier: 'review' } })
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.safety.selectableOnly = true
|
||||
})
|
||||
expect(matchesWorkspaceCleanupFilters(blocked, state, FACET_NOW)).toBe(false)
|
||||
expect(matchesWorkspaceCleanupFilters(makeFacets(), state, FACET_NOW)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('query pipeline', () => {
|
||||
it('reports counts and selectable ids', () => {
|
||||
const rows = [
|
||||
makeNamedFacets('alpha'),
|
||||
makeNamedFacets('beta', { candidate: { blockers: ['dirty-files'], tier: 'review' } })
|
||||
]
|
||||
expect(filterWorkspaceCleanupFacets(rows, ANY, FACET_NOW)).toHaveLength(2)
|
||||
const result = queryWorkspaceCleanupCandidates(
|
||||
buildWorkspaceCleanupFacetList([makeFacetCandidate()]).map((f) => f.candidate),
|
||||
{ filters: ANY, sort: { field: 'name', direction: 'asc' } },
|
||||
{},
|
||||
FACET_NOW
|
||||
)
|
||||
expect(result.totalCount).toBe(1)
|
||||
expect(result.matchedCount).toBe(1)
|
||||
expect(result.selectableWorktreeIds).toEqual(['repo-1::/repo/alpha'])
|
||||
})
|
||||
|
||||
it('counts per-facet matches independently of the other groups', () => {
|
||||
const rows = [makeNamedFacets('alpha', { sizeBytes: 10 }), makeNamedFacets('beta')]
|
||||
const state = filters((s) => {
|
||||
s.safety.dismissed = 'any'
|
||||
s.size.includeUnsized = false
|
||||
s.git.states = ['dirty']
|
||||
})
|
||||
const counts = countWorkspaceCleanupFacetMatches(rows, state, FACET_NOW)
|
||||
expect(counts.size).toBe(1)
|
||||
expect(counts.git).toBe(0)
|
||||
expect(counts.safety).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { HostedReviewProvider } from '../../../../shared/hosted-review'
|
||||
import type { WorkspaceCleanupTier } from '../../../../shared/workspace-cleanup'
|
||||
import type {
|
||||
WorkspaceCleanupAgentState,
|
||||
WorkspaceCleanupGitState,
|
||||
WorkspaceCleanupIdleSignal,
|
||||
WorkspaceCleanupPresence,
|
||||
WorkspaceCleanupReviewState,
|
||||
WorkspaceCleanupSortField,
|
||||
WorkspaceCleanupTicketSource,
|
||||
WorkspaceCleanupTriState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
|
||||
export function getWorkspaceCleanupSortFieldLabel(field: WorkspaceCleanupSortField): string {
|
||||
switch (field) {
|
||||
case 'last-activity':
|
||||
return translate('components.workspace.cleanup.browse.sort.lastActivity', 'Last activity')
|
||||
case 'last-visited':
|
||||
return translate('components.workspace.cleanup.browse.sort.lastVisited', 'Last opened')
|
||||
case 'created':
|
||||
return translate('components.workspace.cleanup.browse.sort.created', 'Created')
|
||||
case 'size':
|
||||
return translate('components.workspace.cleanup.browse.sort.size', 'Size')
|
||||
case 'name':
|
||||
return translate('components.workspace.cleanup.browse.sort.name', 'Workspace')
|
||||
case 'repo':
|
||||
return translate('components.workspace.cleanup.browse.sort.repo', 'Repository')
|
||||
case 'path':
|
||||
return translate('components.workspace.cleanup.browse.sort.path', 'Path')
|
||||
case 'host':
|
||||
return translate('components.workspace.cleanup.browse.sort.host', 'Host')
|
||||
case 'workspace-status':
|
||||
return translate('components.workspace.cleanup.browse.sort.workspaceStatus', 'Status')
|
||||
case 'agent':
|
||||
return translate('components.workspace.cleanup.browse.sort.agent', 'Agent')
|
||||
case 'git':
|
||||
return translate('components.workspace.cleanup.browse.sort.git', 'Git')
|
||||
case 'ahead':
|
||||
return translate('components.workspace.cleanup.browse.sort.ahead', 'Ahead')
|
||||
case 'behind':
|
||||
return translate('components.workspace.cleanup.browse.sort.behind', 'Behind')
|
||||
case 'branch':
|
||||
return translate('components.workspace.cleanup.browse.sort.branch', 'Branch')
|
||||
case 'review':
|
||||
return translate('components.workspace.cleanup.browse.sort.review', 'Review')
|
||||
case 'ticket':
|
||||
return translate('components.workspace.cleanup.browse.sort.ticket', 'Ticket')
|
||||
case 'local-context':
|
||||
return translate('components.workspace.cleanup.browse.sort.localContext', 'Open context')
|
||||
case 'tier':
|
||||
return translate('components.workspace.cleanup.browse.sort.tier', 'Safety')
|
||||
case 'blocker-count':
|
||||
return translate('components.workspace.cleanup.browse.sort.blockerCount', 'Blockers')
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupGitStateLabel(state: WorkspaceCleanupGitState): string {
|
||||
switch (state) {
|
||||
case 'clean':
|
||||
return translate('components.workspace.cleanup.browse.git.clean', 'Clean')
|
||||
case 'dirty':
|
||||
return translate('components.workspace.cleanup.browse.git.dirty', 'Uncommitted changes')
|
||||
case 'unpushed':
|
||||
return translate('components.workspace.cleanup.browse.git.unpushed', 'Unpushed commits')
|
||||
case 'unknown':
|
||||
return translate('components.workspace.cleanup.browse.git.unknown', 'Not checked')
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupAgentStateLabel(state: WorkspaceCleanupAgentState): string {
|
||||
switch (state) {
|
||||
case 'working':
|
||||
return translate('components.workspace.cleanup.browse.agent.working', 'Working')
|
||||
case 'permission':
|
||||
return translate('components.workspace.cleanup.browse.agent.permission', 'Waiting on you')
|
||||
case 'idle':
|
||||
return translate('components.workspace.cleanup.browse.agent.idle', 'Idle')
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupReviewStateLabel(state: WorkspaceCleanupReviewState): string {
|
||||
switch (state) {
|
||||
case 'open':
|
||||
return translate('components.workspace.cleanup.browse.review.open', 'Open')
|
||||
case 'draft':
|
||||
return translate('components.workspace.cleanup.browse.review.draft', 'Draft')
|
||||
case 'merged':
|
||||
return translate('components.workspace.cleanup.browse.review.merged', 'Merged')
|
||||
case 'closed':
|
||||
return translate('components.workspace.cleanup.browse.review.closed', 'Closed')
|
||||
case 'unknown':
|
||||
return translate('components.workspace.cleanup.browse.review.unknown', 'Unknown')
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider-general: GitLab MRs and Azure DevOps PRs are first-class here. */
|
||||
export function getWorkspaceCleanupReviewProviderLabel(provider: HostedReviewProvider): string {
|
||||
switch (provider) {
|
||||
case 'github':
|
||||
return 'GitHub'
|
||||
case 'gitlab':
|
||||
return 'GitLab'
|
||||
case 'bitbucket':
|
||||
return 'Bitbucket'
|
||||
case 'azure-devops':
|
||||
return 'Azure DevOps'
|
||||
case 'gitea':
|
||||
return 'Gitea'
|
||||
case 'unsupported':
|
||||
return translate('components.workspace.cleanup.browse.review.otherProvider', 'Other')
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupTicketSourceLabel(source: WorkspaceCleanupTicketSource): string {
|
||||
switch (source) {
|
||||
case 'work-item':
|
||||
return translate('components.workspace.cleanup.browse.ticket.workItem', 'Work item')
|
||||
case 'linear':
|
||||
return translate('components.workspace.cleanup.browse.ticket.linear', 'Linear')
|
||||
case 'issue':
|
||||
return translate('components.workspace.cleanup.browse.ticket.issue', 'Issue')
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupTierLabel(tier: WorkspaceCleanupTier): string {
|
||||
switch (tier) {
|
||||
case 'ready':
|
||||
return translate('components.workspace.cleanup.browse.tier.ready', 'Ready')
|
||||
case 'review':
|
||||
return translate('components.workspace.cleanup.browse.tier.review', 'Needs review')
|
||||
case 'protected':
|
||||
return translate('components.workspace.cleanup.browse.tier.protected', 'Protected')
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupIdleSignalLabel(signal: WorkspaceCleanupIdleSignal): string {
|
||||
switch (signal) {
|
||||
case 'last-visited':
|
||||
return translate('components.workspace.cleanup.browse.idleSignal.lastVisited', 'Not opened')
|
||||
case 'last-activity':
|
||||
return translate('components.workspace.cleanup.browse.idleSignal.lastActivity', 'No activity')
|
||||
case 'created':
|
||||
return translate('components.workspace.cleanup.browse.idleSignal.created', 'Created before')
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupTriStateLabel(value: WorkspaceCleanupTriState): string {
|
||||
switch (value) {
|
||||
case 'any':
|
||||
return translate('components.workspace.cleanup.browse.triState.any', 'Any')
|
||||
case 'only':
|
||||
return translate('components.workspace.cleanup.browse.triState.only', 'Only')
|
||||
case 'exclude':
|
||||
return translate('components.workspace.cleanup.browse.triState.exclude', 'Exclude')
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupPresenceLabel(value: WorkspaceCleanupPresence): string {
|
||||
switch (value) {
|
||||
case 'any':
|
||||
return translate('components.workspace.cleanup.browse.presence.any', 'Any')
|
||||
case 'some':
|
||||
return translate('components.workspace.cleanup.browse.presence.some', 'Has one')
|
||||
case 'none':
|
||||
return translate('components.workspace.cleanup.browse.presence.none', 'Has none')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets'
|
||||
import type {
|
||||
WorkspaceCleanupActivityFilter,
|
||||
WorkspaceCleanupAgentFilter,
|
||||
WorkspaceCleanupContextFilterState,
|
||||
WorkspaceCleanupGitFilterState,
|
||||
WorkspaceCleanupLocationFilter,
|
||||
WorkspaceCleanupPresence,
|
||||
WorkspaceCleanupReviewFilterState,
|
||||
WorkspaceCleanupSafetyFilter,
|
||||
WorkspaceCleanupSizeFilter,
|
||||
WorkspaceCleanupStatusFilter,
|
||||
WorkspaceCleanupTicketFilter,
|
||||
WorkspaceCleanupTriState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
|
||||
export const WORKSPACE_CLEANUP_DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export function matchesWorkspaceCleanupTriState(
|
||||
filter: WorkspaceCleanupTriState,
|
||||
value: boolean
|
||||
): boolean {
|
||||
return filter === 'any' || (filter === 'only' ? value : !value)
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupPresence(
|
||||
filter: WorkspaceCleanupPresence,
|
||||
value: boolean
|
||||
): boolean {
|
||||
return filter === 'any' || (filter === 'some' ? value : !value)
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupActivity(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupActivityFilter,
|
||||
now: number
|
||||
): boolean {
|
||||
if (filter.neverVisited && facets.lastVisitedAt !== null) {
|
||||
return false
|
||||
}
|
||||
if (filter.idleMinDays === null) {
|
||||
return true
|
||||
}
|
||||
const signalAt = getIdleSignalAt(facets, filter)
|
||||
// Why: a missing signal means Orca has no evidence of recent use, so it
|
||||
// reads as maximally idle rather than being filtered out.
|
||||
return signalAt === null || now - signalAt >= filter.idleMinDays * WORKSPACE_CLEANUP_DAY_MS
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupSize(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupSizeFilter
|
||||
): boolean {
|
||||
if (facets.sizeBytes === null) {
|
||||
return filter.includeUnsized
|
||||
}
|
||||
if (filter.minBytes !== null && facets.sizeBytes < filter.minBytes) {
|
||||
return false
|
||||
}
|
||||
return filter.maxBytes === null || facets.sizeBytes <= filter.maxBytes
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupStatus(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupStatusFilter
|
||||
): boolean {
|
||||
if (filter.workspaceStatuses.length > 0 && !matchesWorkspaceStatusList(facets, filter)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
matchesWorkspaceCleanupTriState(filter.archived, facets.isArchived) &&
|
||||
matchesWorkspaceCleanupTriState(filter.pinned, facets.isPinned) &&
|
||||
matchesWorkspaceCleanupTriState(filter.unread, facets.isUnread) &&
|
||||
matchesWorkspaceCleanupTriState(filter.comment, facets.hasComment)
|
||||
)
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupAgent(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupAgentFilter
|
||||
): boolean {
|
||||
if (filter.states.length > 0 && !filter.states.includes(facets.agentState)) {
|
||||
return false
|
||||
}
|
||||
return matchesWorkspaceCleanupTriState(
|
||||
filter.retainedDoneAgents,
|
||||
facets.retainedDoneAgentCount > 0
|
||||
)
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupGit(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupGitFilterState
|
||||
): boolean {
|
||||
if (filter.states.length > 0 && !filter.states.includes(facets.gitState)) {
|
||||
return false
|
||||
}
|
||||
if (filter.minAhead !== null && (facets.upstreamAhead ?? 0) < filter.minAhead) {
|
||||
return false
|
||||
}
|
||||
if (filter.minBehind !== null && (facets.upstreamBehind ?? 0) < filter.minBehind) {
|
||||
return false
|
||||
}
|
||||
const branchQuery = filter.branchQuery.trim().toLowerCase()
|
||||
if (branchQuery && !facets.branch.toLowerCase().includes(branchQuery)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
matchesWorkspaceCleanupTriState(filter.prunable, facets.isPrunable) &&
|
||||
matchesWorkspaceCleanupTriState(filter.locked, facets.isLocked)
|
||||
)
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupReview(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupReviewFilterState
|
||||
): boolean {
|
||||
if (!matchesWorkspaceCleanupPresence(filter.presence, facets.review.hasReview)) {
|
||||
return false
|
||||
}
|
||||
if (filter.states.length > 0) {
|
||||
if (facets.reviewState === null || !filter.states.includes(facets.reviewState)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (filter.providers.length === 0) {
|
||||
return true
|
||||
}
|
||||
return facets.review.provider !== null && filter.providers.includes(facets.review.provider)
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupTicket(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupTicketFilter
|
||||
): boolean {
|
||||
if (!matchesWorkspaceCleanupPresence(filter.presence, facets.ticketSources.length > 0)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
filter.sources.length === 0 ||
|
||||
filter.sources.some((source) => facets.ticketSources.includes(source))
|
||||
)
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupContext(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupContextFilterState
|
||||
): boolean {
|
||||
if (!matchesWorkspaceCleanupPresence(filter.presence, facets.hasLocalContext)) {
|
||||
return false
|
||||
}
|
||||
return !filter.completelyEmpty || facets.isCompletelyEmpty
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupLocation(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupLocationFilter
|
||||
): boolean {
|
||||
if (filter.hostIds.length > 0 && !filter.hostIds.includes(facets.hostId)) {
|
||||
return false
|
||||
}
|
||||
if (filter.repoIds.length > 0 && !filter.repoIds.includes(facets.repoId)) {
|
||||
return false
|
||||
}
|
||||
const prefix = filter.pathPrefix.trim()
|
||||
return prefix.length === 0 || facets.path.startsWith(prefix)
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupSafety(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupSafetyFilter
|
||||
): boolean {
|
||||
if (filter.tiers.length > 0 && !filter.tiers.includes(facets.tier)) {
|
||||
return false
|
||||
}
|
||||
if (!matchesWorkspaceCleanupTriState(filter.dismissed, facets.isDismissed)) {
|
||||
return false
|
||||
}
|
||||
if (filter.selectableOnly && !facets.isSelectable) {
|
||||
return false
|
||||
}
|
||||
if (filter.blockers.length === 0) {
|
||||
return true
|
||||
}
|
||||
const hit = filter.blockers.some((blocker) => facets.blockers.includes(blocker))
|
||||
return filter.blockerMode === 'any-of' ? hit : !hit
|
||||
}
|
||||
|
||||
function matchesWorkspaceStatusList(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupStatusFilter
|
||||
): boolean {
|
||||
if (facets.workspaceStatus === null) {
|
||||
return filter.matchStatusless
|
||||
}
|
||||
return filter.workspaceStatuses.includes(facets.workspaceStatus)
|
||||
}
|
||||
|
||||
function getIdleSignalAt(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filter: WorkspaceCleanupActivityFilter
|
||||
): number | null {
|
||||
switch (filter.idleSignal) {
|
||||
case 'last-visited':
|
||||
return facets.lastVisitedAt
|
||||
case 'last-activity':
|
||||
return facets.lastActivityAt
|
||||
case 'created':
|
||||
return facets.createdAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { HostedReviewProvider } from '../../../../shared/hosted-review'
|
||||
import type { WorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
|
||||
|
||||
export type WorkspaceCleanupFacetCounts = Record<
|
||||
keyof Omit<WorkspaceCleanupFilterState, 'query'>,
|
||||
number
|
||||
>
|
||||
|
||||
/** Facet values discovered from the loaded fleet, not a hardcoded enum. */
|
||||
export type WorkspaceCleanupFacetOptions = {
|
||||
workspaceStatuses: readonly { id: string; label: string }[]
|
||||
hostIds: readonly ExecutionHostId[]
|
||||
repos: readonly { id: string; label: string }[]
|
||||
reviewProviders: readonly HostedReviewProvider[]
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupFacetGroupProps = {
|
||||
filters: WorkspaceCleanupFilterState
|
||||
counts: WorkspaceCleanupFacetCounts
|
||||
totalCount: number
|
||||
options: WorkspaceCleanupFacetOptions
|
||||
onPatch: <K extends keyof WorkspaceCleanupFilterState>(
|
||||
key: K,
|
||||
value: Partial<WorkspaceCleanupFilterState[K]> | WorkspaceCleanupFilterState[K]
|
||||
) => void
|
||||
}
|
||||
|
||||
export const WORKSPACE_CLEANUP_MEGABYTE = 1024 * 1024
|
||||
|
||||
export function toMegabytes(bytes: number | null): number | null {
|
||||
return bytes === null ? null : Math.round(bytes / WORKSPACE_CLEANUP_MEGABYTE)
|
||||
}
|
||||
|
||||
export function fromMegabytes(megabytes: number | null): number | null {
|
||||
return megabytes === null ? null : Math.round(megabytes * WORKSPACE_CLEANUP_MEGABYTE)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DAY, FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture'
|
||||
import {
|
||||
isWorkspaceCleanupAbsentLastSortField,
|
||||
sortWorkspaceCleanupFacets
|
||||
} from './workspace-cleanup-facet-sort'
|
||||
import type { WorkspaceCleanupSortField } from '../../../../shared/workspace-cleanup-filter-model'
|
||||
|
||||
function names(
|
||||
rows: ReturnType<typeof makeNamedFacets>[],
|
||||
field: WorkspaceCleanupSortField,
|
||||
direction: 'asc' | 'desc'
|
||||
): string[] {
|
||||
return sortWorkspaceCleanupFacets(rows, { field, direction }).map((row) => row.displayName)
|
||||
}
|
||||
|
||||
describe('absent values', () => {
|
||||
it('sinks unsized workspaces to the bottom in both directions', () => {
|
||||
const rows = [
|
||||
makeNamedFacets('unsized'),
|
||||
makeNamedFacets('small', { sizeBytes: 10 }),
|
||||
makeNamedFacets('big', { sizeBytes: 900 })
|
||||
]
|
||||
expect(names(rows, 'size', 'desc')).toEqual(['big', 'small', 'unsized'])
|
||||
expect(names(rows, 'size', 'asc')).toEqual(['small', 'big', 'unsized'])
|
||||
})
|
||||
|
||||
it('sinks never-visited workspaces to the bottom in both directions', () => {
|
||||
const rows = [
|
||||
makeNamedFacets('never'),
|
||||
makeNamedFacets('old', { lastVisitedAt: FACET_NOW - 50 * DAY }),
|
||||
makeNamedFacets('recent', { lastVisitedAt: FACET_NOW })
|
||||
]
|
||||
expect(names(rows, 'last-visited', 'asc')).toEqual(['old', 'recent', 'never'])
|
||||
expect(names(rows, 'last-visited', 'desc')).toEqual(['recent', 'old', 'never'])
|
||||
})
|
||||
|
||||
it('sinks review-less and ticket-less workspaces to the bottom', () => {
|
||||
const rows = [
|
||||
makeNamedFacets('none'),
|
||||
makeNamedFacets('merged', { review: { state: 'merged' } }),
|
||||
makeNamedFacets('open', { review: { state: 'open' } })
|
||||
]
|
||||
expect(names(rows, 'review', 'desc')).toEqual(['open', 'merged', 'none'])
|
||||
|
||||
const ticketRows = [
|
||||
makeNamedFacets('untracked'),
|
||||
makeNamedFacets('linear', { worktree: { linkedLinearIssue: 'STA-1' } })
|
||||
]
|
||||
expect(names(ticketRows, 'ticket', 'asc')).toEqual(['linear', 'untracked'])
|
||||
})
|
||||
|
||||
it('marks exactly the optional-value fields as absent-last', () => {
|
||||
expect(isWorkspaceCleanupAbsentLastSortField('size')).toBe(true)
|
||||
expect(isWorkspaceCleanupAbsentLastSortField('name')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ranked fields', () => {
|
||||
it('orders git states from safest to riskiest', () => {
|
||||
const rows = [
|
||||
makeNamedFacets('unpushed', {
|
||||
candidate: { git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } }
|
||||
}),
|
||||
makeNamedFacets('clean'),
|
||||
makeNamedFacets('dirty', {
|
||||
candidate: { git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } }
|
||||
}),
|
||||
makeNamedFacets('unknown', {
|
||||
candidate: { git: { clean: null, upstreamAhead: 0, upstreamBehind: 0, checkedAt: null } }
|
||||
})
|
||||
]
|
||||
expect(names(rows, 'git', 'asc')).toEqual(['clean', 'unknown', 'dirty', 'unpushed'])
|
||||
})
|
||||
|
||||
it('orders tiers ready to protected and agents idle to permission', () => {
|
||||
const tierRows = [
|
||||
makeNamedFacets('protectedRow', { candidate: { tier: 'protected' } }),
|
||||
makeNamedFacets('readyRow', { candidate: { tier: 'ready' } }),
|
||||
makeNamedFacets('reviewRow', { candidate: { tier: 'review' } })
|
||||
]
|
||||
expect(names(tierRows, 'tier', 'asc')).toEqual(['readyRow', 'reviewRow', 'protectedRow'])
|
||||
|
||||
const agentRows = [
|
||||
makeNamedFacets('permission', { agentStatus: 'permission' }),
|
||||
makeNamedFacets('idle'),
|
||||
makeNamedFacets('working', { agentStatus: 'working' })
|
||||
]
|
||||
expect(names(agentRows, 'agent', 'desc')).toEqual(['permission', 'working', 'idle'])
|
||||
})
|
||||
|
||||
it('breaks equal blocker counts by the worst blocker', () => {
|
||||
const rows = [
|
||||
makeNamedFacets('softest', { candidate: { blockers: ['git-status-error'] } }),
|
||||
makeNamedFacets('hardest', { candidate: { blockers: ['main-worktree'] } })
|
||||
]
|
||||
expect(names(rows, 'blocker-count', 'desc')).toEqual(['hardest', 'softest'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tie-breaks', () => {
|
||||
it('falls back to activity, repo, name, then worktree id', () => {
|
||||
const rows = [
|
||||
makeNamedFacets('zeta', { candidate: { lastActivityAt: FACET_NOW - DAY } }),
|
||||
makeNamedFacets('alpha', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } })
|
||||
]
|
||||
// Same size on both sides, so only the tie-break chain can order them.
|
||||
expect(names(rows, 'size', 'desc')).toEqual(['alpha', 'zeta'])
|
||||
})
|
||||
|
||||
it('keeps tie-broken rows in the same order when direction flips', () => {
|
||||
const rows = [
|
||||
makeNamedFacets('b', { candidate: { lastActivityAt: FACET_NOW - DAY } }),
|
||||
makeNamedFacets('a', { candidate: { lastActivityAt: FACET_NOW - 2 * DAY } })
|
||||
]
|
||||
expect(names(rows, 'git', 'asc')).toEqual(names(rows, 'git', 'desc'))
|
||||
})
|
||||
|
||||
it('does not mutate the input array', () => {
|
||||
const rows = [makeNamedFacets('b'), makeNamedFacets('a')]
|
||||
const before = rows.map((row) => row.displayName)
|
||||
sortWorkspaceCleanupFacets(rows, { field: 'name', direction: 'asc' })
|
||||
expect(rows.map((row) => row.displayName)).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('text fields', () => {
|
||||
it('sorts by name, repo, path, branch, and host', () => {
|
||||
const rows = [makeNamedFacets('charlie'), makeNamedFacets('alpha'), makeNamedFacets('bravo')]
|
||||
expect(names(rows, 'name', 'asc')).toEqual(['alpha', 'bravo', 'charlie'])
|
||||
expect(names(rows, 'path', 'desc')).toEqual(['charlie', 'bravo', 'alpha'])
|
||||
expect(names(rows, 'branch', 'asc')).toEqual(['alpha', 'bravo', 'charlie'])
|
||||
|
||||
const hostRows = [
|
||||
makeNamedFacets('remote', { worktree: { hostId: 'ssh:builder' } }),
|
||||
makeNamedFacets('here', { worktree: { hostId: 'local' } })
|
||||
]
|
||||
expect(names(hostRows, 'host', 'asc')).toEqual(['here', 'remote'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets'
|
||||
import {
|
||||
getWorkspaceCleanupAgentRank,
|
||||
getWorkspaceCleanupBlockerSeverity,
|
||||
getWorkspaceCleanupGitRank,
|
||||
getWorkspaceCleanupReviewRank,
|
||||
getWorkspaceCleanupTierRank
|
||||
} from '../../../../shared/workspace-cleanup-facet-rankings'
|
||||
import type {
|
||||
WorkspaceCleanupSortDirectionState,
|
||||
WorkspaceCleanupSortField,
|
||||
WorkspaceCleanupSortState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
|
||||
/**
|
||||
* Fields whose value can be absent. Absent rows always sink to the bottom in
|
||||
* BOTH directions — flipping direction should reorder the rows that carry the
|
||||
* signal, not promote the ones that carry nothing.
|
||||
*/
|
||||
const ABSENT_LAST_FIELDS: ReadonlySet<WorkspaceCleanupSortField> = new Set([
|
||||
'size',
|
||||
'last-visited',
|
||||
'created',
|
||||
'ahead',
|
||||
'behind',
|
||||
'workspace-status',
|
||||
'review',
|
||||
'ticket'
|
||||
])
|
||||
|
||||
export function isWorkspaceCleanupAbsentLastSortField(field: WorkspaceCleanupSortField): boolean {
|
||||
return ABSENT_LAST_FIELDS.has(field)
|
||||
}
|
||||
|
||||
export function sortWorkspaceCleanupFacets(
|
||||
facets: readonly WorkspaceCleanupFacets[],
|
||||
sort: WorkspaceCleanupSortState
|
||||
): WorkspaceCleanupFacets[] {
|
||||
const multiplier = sort.direction === 'asc' ? 1 : -1
|
||||
return [...facets].sort(
|
||||
(left, right) =>
|
||||
comparePrimary(left, right, sort.field, multiplier) || compareTieBreaks(left, right)
|
||||
)
|
||||
}
|
||||
|
||||
export function compareWorkspaceCleanupFacets(
|
||||
left: WorkspaceCleanupFacets,
|
||||
right: WorkspaceCleanupFacets,
|
||||
field: WorkspaceCleanupSortField,
|
||||
direction: WorkspaceCleanupSortDirectionState
|
||||
): number {
|
||||
const multiplier = direction === 'asc' ? 1 : -1
|
||||
return comparePrimary(left, right, field, multiplier) || compareTieBreaks(left, right)
|
||||
}
|
||||
|
||||
function comparePrimary(
|
||||
left: WorkspaceCleanupFacets,
|
||||
right: WorkspaceCleanupFacets,
|
||||
field: WorkspaceCleanupSortField,
|
||||
multiplier: number
|
||||
): number {
|
||||
const absence = compareAbsence(left, right, field)
|
||||
if (absence !== 0) {
|
||||
return absence
|
||||
}
|
||||
return compareNatural(left, right, field) * multiplier
|
||||
}
|
||||
|
||||
/** Non-zero only when exactly one side is absent for an absent-last field. */
|
||||
function compareAbsence(
|
||||
left: WorkspaceCleanupFacets,
|
||||
right: WorkspaceCleanupFacets,
|
||||
field: WorkspaceCleanupSortField
|
||||
): number {
|
||||
if (!ABSENT_LAST_FIELDS.has(field)) {
|
||||
return 0
|
||||
}
|
||||
const leftAbsent = isAbsent(left, field)
|
||||
const rightAbsent = isAbsent(right, field)
|
||||
if (leftAbsent === rightAbsent) {
|
||||
return 0
|
||||
}
|
||||
return leftAbsent ? 1 : -1
|
||||
}
|
||||
|
||||
function isAbsent(facets: WorkspaceCleanupFacets, field: WorkspaceCleanupSortField): boolean {
|
||||
if (field === 'size') {
|
||||
return facets.sizeBytes === null
|
||||
}
|
||||
if (field === 'last-visited') {
|
||||
return facets.lastVisitedAt === null
|
||||
}
|
||||
if (field === 'created') {
|
||||
return facets.createdAt === null
|
||||
}
|
||||
if (field === 'ahead') {
|
||||
return facets.upstreamAhead === null
|
||||
}
|
||||
if (field === 'behind') {
|
||||
return facets.upstreamBehind === null
|
||||
}
|
||||
if (field === 'workspace-status') {
|
||||
return facets.workspaceStatus === null
|
||||
}
|
||||
if (field === 'review') {
|
||||
return !facets.review.hasReview
|
||||
}
|
||||
if (field === 'ticket') {
|
||||
return facets.ticketSources.length === 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function compareNatural(
|
||||
left: WorkspaceCleanupFacets,
|
||||
right: WorkspaceCleanupFacets,
|
||||
field: WorkspaceCleanupSortField
|
||||
): number {
|
||||
switch (field) {
|
||||
case 'last-activity':
|
||||
return left.lastActivityAt - right.lastActivityAt
|
||||
case 'last-visited':
|
||||
return (left.lastVisitedAt ?? 0) - (right.lastVisitedAt ?? 0)
|
||||
case 'created':
|
||||
return (left.createdAt ?? 0) - (right.createdAt ?? 0)
|
||||
case 'size':
|
||||
return (left.sizeBytes ?? 0) - (right.sizeBytes ?? 0)
|
||||
case 'name':
|
||||
return left.displayName.localeCompare(right.displayName)
|
||||
case 'repo':
|
||||
return left.repoName.localeCompare(right.repoName)
|
||||
case 'path':
|
||||
return left.path.localeCompare(right.path)
|
||||
case 'host':
|
||||
return left.hostId.localeCompare(right.hostId)
|
||||
case 'workspace-status':
|
||||
return (left.workspaceStatus ?? '').localeCompare(right.workspaceStatus ?? '')
|
||||
case 'agent':
|
||||
return (
|
||||
getWorkspaceCleanupAgentRank(left.agentState) -
|
||||
getWorkspaceCleanupAgentRank(right.agentState)
|
||||
)
|
||||
case 'git':
|
||||
return getWorkspaceCleanupGitRank(left.gitState) - getWorkspaceCleanupGitRank(right.gitState)
|
||||
case 'ahead':
|
||||
return (left.upstreamAhead ?? 0) - (right.upstreamAhead ?? 0)
|
||||
case 'behind':
|
||||
return (left.upstreamBehind ?? 0) - (right.upstreamBehind ?? 0)
|
||||
case 'branch':
|
||||
return left.branch.localeCompare(right.branch)
|
||||
case 'review':
|
||||
return (
|
||||
getWorkspaceCleanupReviewRank(left.reviewState) -
|
||||
getWorkspaceCleanupReviewRank(right.reviewState) ||
|
||||
(left.review.label ?? '').localeCompare(right.review.label ?? '')
|
||||
)
|
||||
case 'ticket':
|
||||
return (
|
||||
left.ticketSources.length - right.ticketSources.length ||
|
||||
(left.ticketSources[0] ?? '').localeCompare(right.ticketSources[0] ?? '')
|
||||
)
|
||||
case 'local-context':
|
||||
return left.localContextCount - right.localContextCount
|
||||
case 'tier':
|
||||
return getWorkspaceCleanupTierRank(left.tier) - getWorkspaceCleanupTierRank(right.tier)
|
||||
case 'blocker-count':
|
||||
return (
|
||||
left.blockerCount - right.blockerCount ||
|
||||
getMaxBlockerSeverity(left) - getMaxBlockerSeverity(right)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Direction-independent so a flipped sort never reshuffles equal rows. */
|
||||
function compareTieBreaks(left: WorkspaceCleanupFacets, right: WorkspaceCleanupFacets): number {
|
||||
return (
|
||||
left.lastActivityAt - right.lastActivityAt ||
|
||||
left.repoName.localeCompare(right.repoName) ||
|
||||
left.displayName.localeCompare(right.displayName) ||
|
||||
left.worktreeId.localeCompare(right.worktreeId)
|
||||
)
|
||||
}
|
||||
|
||||
function getMaxBlockerSeverity(facets: WorkspaceCleanupFacets): number {
|
||||
let max = 0
|
||||
for (const blocker of facets.blockers) {
|
||||
max = Math.max(max, getWorkspaceCleanupBlockerSeverity(blocker))
|
||||
}
|
||||
return max
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
buildWorkspaceCleanupFacets,
|
||||
type WorkspaceCleanupFacetSources,
|
||||
type WorkspaceCleanupFacets,
|
||||
type WorkspaceCleanupWorktreeFacts
|
||||
} from './workspace-cleanup-facets'
|
||||
import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation'
|
||||
|
||||
export const FACET_NOW = 1_700_000_000_000
|
||||
export const DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
export function makeFacetCandidate(
|
||||
overrides: Partial<WorkspaceCleanupCandidate> = {}
|
||||
): WorkspaceCleanupCandidate {
|
||||
return {
|
||||
worktreeId: 'repo-1::/repo/alpha',
|
||||
repoId: 'repo-1',
|
||||
repoName: 'Repo',
|
||||
connectionId: null,
|
||||
displayName: 'alpha',
|
||||
branch: 'refs/heads/alpha',
|
||||
path: '/repo/alpha',
|
||||
tier: 'ready',
|
||||
selectedByDefault: true,
|
||||
reasons: ['idle-clean'],
|
||||
blockers: [],
|
||||
lastActivityAt: FACET_NOW - 40 * DAY,
|
||||
localContext: {
|
||||
terminalTabCount: 0,
|
||||
cleanEditorTabCount: 0,
|
||||
browserTabCount: 0,
|
||||
diffCommentCount: 0,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 0
|
||||
},
|
||||
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: FACET_NOW },
|
||||
fingerprint: 'fingerprint',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function makeWorktreeFacts(
|
||||
overrides: Partial<WorkspaceCleanupWorktreeFacts> = {}
|
||||
): WorkspaceCleanupWorktreeFacts {
|
||||
return {
|
||||
id: 'repo-1::/repo/alpha',
|
||||
branch: 'refs/heads/alpha',
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isPinned: false,
|
||||
isUnread: false,
|
||||
linkedIssue: null,
|
||||
linkedLinearIssue: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function makeReviewInfo(
|
||||
overrides: Partial<WorkspaceCleanupReviewInfo> = {}
|
||||
): WorkspaceCleanupReviewInfo {
|
||||
return {
|
||||
hasReview: true,
|
||||
label: 'PR #42',
|
||||
state: 'open',
|
||||
provider: 'github',
|
||||
title: 'Alpha cleanup',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export type FacetFixtureInput = {
|
||||
candidate?: Partial<WorkspaceCleanupCandidate>
|
||||
worktree?: Partial<WorkspaceCleanupWorktreeFacts> | null
|
||||
sizeBytes?: number
|
||||
lastVisitedAt?: number
|
||||
agentStatus?: LiveAgentWorktreeStatus
|
||||
review?: Partial<WorkspaceCleanupReviewInfo>
|
||||
dismissed?: boolean
|
||||
}
|
||||
|
||||
/** Builds through the real facet builder so tests exercise the production join. */
|
||||
export function makeFacets(input: FacetFixtureInput = {}): WorkspaceCleanupFacets {
|
||||
const candidate = makeFacetCandidate(input.candidate)
|
||||
const id = candidate.worktreeId
|
||||
const sources: WorkspaceCleanupFacetSources = {
|
||||
worktreeById:
|
||||
input.worktree === null
|
||||
? new Map()
|
||||
: new Map([[id, makeWorktreeFacts({ id, ...input.worktree })]]),
|
||||
sizeBytesByWorktreeId:
|
||||
input.sizeBytes === undefined ? new Map() : new Map([[id, input.sizeBytes]]),
|
||||
lastVisitedAtByWorktreeId:
|
||||
input.lastVisitedAt === undefined ? {} : { [id]: input.lastVisitedAt },
|
||||
liveAgentStatusByWorktreeId:
|
||||
input.agentStatus === undefined ? new Map() : new Map([[id, input.agentStatus]]),
|
||||
reviewInfoByWorktreeId:
|
||||
input.review === undefined ? new Map() : new Map([[id, makeReviewInfo(input.review)]]),
|
||||
dismissedWorktreeIds: new Set(input.dismissed ? [id] : [])
|
||||
}
|
||||
return buildWorkspaceCleanupFacets(candidate, sources)
|
||||
}
|
||||
|
||||
/** Distinct worktree/display identity so tie-break ordering is observable. */
|
||||
export function makeNamedFacets(
|
||||
name: string,
|
||||
input: FacetFixtureInput = {}
|
||||
): WorkspaceCleanupFacets {
|
||||
return makeFacets({
|
||||
...input,
|
||||
candidate: {
|
||||
worktreeId: `repo-1::/repo/${name}`,
|
||||
displayName: name,
|
||||
path: `/repo/${name}`,
|
||||
branch: `refs/heads/${name}`,
|
||||
...input.candidate
|
||||
},
|
||||
worktree:
|
||||
input.worktree === null
|
||||
? null
|
||||
: { id: `repo-1::/repo/${name}`, branch: `refs/heads/${name}`, ...input.worktree }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
|
||||
import {
|
||||
canSelectWorkspaceCleanupCandidate,
|
||||
type WorkspaceCleanupBlocker,
|
||||
type WorkspaceCleanupCandidate,
|
||||
type WorkspaceCleanupTier
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { getWorkspaceCleanupGitState } from './workspace-cleanup-filter-sort'
|
||||
import type {
|
||||
WorkspaceCleanupAgentState,
|
||||
WorkspaceCleanupGitState,
|
||||
WorkspaceCleanupReviewState,
|
||||
WorkspaceCleanupTicketSource
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation'
|
||||
|
||||
/** Structural subset of `Worktree` — Pick keeps it in sync when the source type moves. */
|
||||
export type WorkspaceCleanupWorktreeFacts = Pick<Worktree, 'id'> &
|
||||
Partial<
|
||||
Pick<
|
||||
Worktree,
|
||||
| 'workspaceStatus'
|
||||
| 'isArchived'
|
||||
| 'isPinned'
|
||||
| 'isUnread'
|
||||
| 'comment'
|
||||
| 'hostId'
|
||||
| 'branch'
|
||||
| 'createdAt'
|
||||
| 'linkedWorkItem'
|
||||
| 'linkedLinearIssue'
|
||||
| 'linkedIssue'
|
||||
| 'locked'
|
||||
| 'prunable'
|
||||
>
|
||||
>
|
||||
|
||||
export type WorkspaceCleanupFacetSources = {
|
||||
worktreeById?: ReadonlyMap<string, WorkspaceCleanupWorktreeFacts>
|
||||
/** Absent entry = the space scan never ran for that worktree. */
|
||||
sizeBytesByWorktreeId?: ReadonlyMap<string, number>
|
||||
lastVisitedAtByWorktreeId?: Readonly<Record<string, number>>
|
||||
liveAgentStatusByWorktreeId?: ReadonlyMap<string, LiveAgentWorktreeStatus>
|
||||
reviewInfoByWorktreeId?: ReadonlyMap<string, WorkspaceCleanupReviewInfo>
|
||||
dismissedWorktreeIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupFacets = {
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
worktreeId: string
|
||||
repoId: string
|
||||
repoName: string
|
||||
displayName: string
|
||||
path: string
|
||||
branch: string
|
||||
hostId: ExecutionHostId
|
||||
tier: WorkspaceCleanupTier
|
||||
blockers: readonly WorkspaceCleanupBlocker[]
|
||||
blockerCount: number
|
||||
isDismissed: boolean
|
||||
isSelectable: boolean
|
||||
/** Background signal: ambient PTY/agent churn bumps this without a human. */
|
||||
lastActivityAt: number
|
||||
createdAt: number | null
|
||||
/** Honest "user opened it" signal; null when Orca never recorded a visit. */
|
||||
lastVisitedAt: number | null
|
||||
sizeBytes: number | null
|
||||
workspaceStatus: string | null
|
||||
isArchived: boolean
|
||||
isPinned: boolean
|
||||
isUnread: boolean
|
||||
hasComment: boolean
|
||||
agentState: WorkspaceCleanupAgentState
|
||||
retainedDoneAgentCount: number
|
||||
gitState: WorkspaceCleanupGitState
|
||||
upstreamAhead: number | null
|
||||
upstreamBehind: number | null
|
||||
isPrunable: boolean
|
||||
isLocked: boolean
|
||||
review: WorkspaceCleanupReviewInfo
|
||||
reviewState: WorkspaceCleanupReviewState | null
|
||||
ticketSources: readonly WorkspaceCleanupTicketSource[]
|
||||
localContextCount: number
|
||||
hasLocalContext: boolean
|
||||
isCompletelyEmpty: boolean
|
||||
searchText: string
|
||||
}
|
||||
|
||||
const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = {
|
||||
hasReview: false,
|
||||
label: null,
|
||||
state: null,
|
||||
provider: null,
|
||||
title: null
|
||||
}
|
||||
|
||||
export function buildWorkspaceCleanupFacets(
|
||||
candidate: WorkspaceCleanupCandidate,
|
||||
sources: WorkspaceCleanupFacetSources = {}
|
||||
): WorkspaceCleanupFacets {
|
||||
const worktree = sources.worktreeById?.get(candidate.worktreeId) ?? null
|
||||
const review = sources.reviewInfoByWorktreeId?.get(candidate.worktreeId) ?? EMPTY_REVIEW_INFO
|
||||
const ticketSources = getTicketSources(worktree)
|
||||
const localContextCount = getLocalContextCount(candidate)
|
||||
const hasComment = (worktree?.comment ?? '').trim().length > 0
|
||||
const branch = getBranchDisplayName(worktree?.branch ?? candidate.branch)
|
||||
const facets: Omit<WorkspaceCleanupFacets, 'searchText'> = {
|
||||
candidate,
|
||||
worktreeId: candidate.worktreeId,
|
||||
repoId: candidate.repoId,
|
||||
repoName: candidate.repoName,
|
||||
displayName: candidate.displayName,
|
||||
path: candidate.path,
|
||||
branch,
|
||||
hostId: worktree?.hostId ?? LOCAL_EXECUTION_HOST_ID,
|
||||
tier: candidate.tier,
|
||||
blockers: candidate.blockers,
|
||||
blockerCount: candidate.blockers.length,
|
||||
isDismissed:
|
||||
(sources.dismissedWorktreeIds?.has(candidate.worktreeId) ?? false) ||
|
||||
candidate.blockers.includes('dismissed'),
|
||||
isSelectable: canSelectWorkspaceCleanupCandidate(candidate),
|
||||
lastActivityAt: candidate.lastActivityAt,
|
||||
createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt),
|
||||
lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]),
|
||||
sizeBytes: toFiniteOrNull(sources.sizeBytesByWorktreeId?.get(candidate.worktreeId)),
|
||||
workspaceStatus: normalizeStatus(worktree?.workspaceStatus),
|
||||
isArchived: worktree?.isArchived ?? candidate.reasons.includes('archived'),
|
||||
isPinned: worktree?.isPinned ?? candidate.blockers.includes('pinned'),
|
||||
isUnread: worktree?.isUnread ?? false,
|
||||
hasComment,
|
||||
agentState: sources.liveAgentStatusByWorktreeId?.get(candidate.worktreeId) ?? 'idle',
|
||||
retainedDoneAgentCount: candidate.localContext.retainedDoneAgentCount,
|
||||
gitState: getWorkspaceCleanupGitState(candidate),
|
||||
upstreamAhead: toFiniteOrNull(candidate.git.upstreamAhead),
|
||||
upstreamBehind: toFiniteOrNull(candidate.git.upstreamBehind),
|
||||
isPrunable: worktree?.prunable ?? false,
|
||||
isLocked: worktree?.locked ?? false,
|
||||
review,
|
||||
reviewState: review.hasReview ? (review.state ?? 'unknown') : null,
|
||||
ticketSources,
|
||||
localContextCount,
|
||||
hasLocalContext: localContextCount > 0,
|
||||
isCompletelyEmpty:
|
||||
localContextCount === 0 && !review.hasReview && ticketSources.length === 0 && !hasComment
|
||||
}
|
||||
return { ...facets, searchText: buildSearchText(facets) }
|
||||
}
|
||||
|
||||
export function buildWorkspaceCleanupFacetList(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
sources: WorkspaceCleanupFacetSources = {}
|
||||
): WorkspaceCleanupFacets[] {
|
||||
return candidates.map((candidate) => buildWorkspaceCleanupFacets(candidate, sources))
|
||||
}
|
||||
|
||||
/** Only `ok` scans carry a trustworthy byte count; everything else stays unsized. */
|
||||
export function buildWorkspaceCleanupSizeIndex(
|
||||
worktrees: readonly WorkspaceSpaceWorktree[] | null | undefined
|
||||
): Map<string, number> {
|
||||
const index = new Map<string, number>()
|
||||
for (const entry of worktrees ?? []) {
|
||||
if (entry.status === 'ok' && Number.isFinite(entry.sizeBytes)) {
|
||||
index.set(entry.worktreeId, entry.sizeBytes)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
export function buildWorkspaceCleanupWorktreeIndex(
|
||||
worktreesByRepo: Readonly<Record<string, readonly WorkspaceCleanupWorktreeFacts[]>>
|
||||
): Map<string, WorkspaceCleanupWorktreeFacts> {
|
||||
const index = new Map<string, WorkspaceCleanupWorktreeFacts>()
|
||||
for (const worktrees of Object.values(worktreesByRepo)) {
|
||||
for (const worktree of worktrees) {
|
||||
index.set(worktree.id, worktree)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function getLocalContextCount(candidate: WorkspaceCleanupCandidate): number {
|
||||
const context = candidate.localContext
|
||||
return (
|
||||
context.terminalTabCount +
|
||||
context.cleanEditorTabCount +
|
||||
context.browserTabCount +
|
||||
context.diffCommentCount +
|
||||
context.retainedDoneAgentCount
|
||||
)
|
||||
}
|
||||
|
||||
function getTicketSources(
|
||||
worktree: WorkspaceCleanupWorktreeFacts | null
|
||||
): WorkspaceCleanupTicketSource[] {
|
||||
if (!worktree) {
|
||||
return []
|
||||
}
|
||||
const sources: WorkspaceCleanupTicketSource[] = []
|
||||
if (worktree.linkedWorkItem != null) {
|
||||
sources.push('work-item')
|
||||
}
|
||||
if ((worktree.linkedLinearIssue ?? '').length > 0) {
|
||||
sources.push('linear')
|
||||
}
|
||||
if (worktree.linkedIssue != null) {
|
||||
sources.push('issue')
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
function buildSearchText(facets: Omit<WorkspaceCleanupFacets, 'searchText'>): string {
|
||||
return [
|
||||
facets.displayName,
|
||||
facets.repoName,
|
||||
facets.branch,
|
||||
facets.path,
|
||||
facets.hostId,
|
||||
facets.workspaceStatus,
|
||||
facets.review.label,
|
||||
facets.review.title,
|
||||
facets.review.provider,
|
||||
facets.gitState,
|
||||
facets.tier,
|
||||
...facets.ticketSources,
|
||||
...facets.blockers
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeStatus(status: string | undefined): string | null {
|
||||
const trimmed = (status ?? '').trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
function toFiniteOrNull(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function getBranchDisplayName(branch: string): string {
|
||||
return branch.replace(/^refs\/heads\//, '') || 'HEAD'
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react'
|
||||
import { HardDrive, Loader2, RotateCcw, Search, SlidersHorizontal } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { WorkspaceCleanupGitReviewFacets } from './workspace-cleanup-git-review-facets'
|
||||
import { WorkspaceCleanupLifecycleFacets } from './workspace-cleanup-lifecycle-facets'
|
||||
import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model'
|
||||
|
||||
export type WorkspaceCleanupSizeScanState = {
|
||||
measuredCount: number
|
||||
scanning: boolean
|
||||
onRun: () => void
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupGitEvidenceProgress = {
|
||||
pendingCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export function WorkspaceCleanupFilterBar({
|
||||
facetProps,
|
||||
activeFacetGroupCount,
|
||||
matchedCount,
|
||||
hasActiveFilters,
|
||||
sizeScan,
|
||||
gitEvidence,
|
||||
onQueryChange,
|
||||
onClearFilters
|
||||
}: {
|
||||
facetProps: WorkspaceCleanupFacetGroupProps
|
||||
activeFacetGroupCount: number
|
||||
matchedCount: number
|
||||
hasActiveFilters: boolean
|
||||
sizeScan: WorkspaceCleanupSizeScanState
|
||||
gitEvidence: WorkspaceCleanupGitEvidenceProgress
|
||||
onQueryChange: (query: string) => void
|
||||
onClearFilters: () => void
|
||||
}): React.JSX.Element {
|
||||
const { filters, totalCount } = facetProps
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-muted/15 px-3 py-2">
|
||||
<div className="relative min-w-[180px] flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={filters.query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={translate(
|
||||
'components.workspace.cleanup.browse.searchPlaceholder',
|
||||
'Search name, repo, branch, path, host'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'components.workspace.cleanup.browse.searchLabel',
|
||||
'Search workspaces'
|
||||
)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Popover modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="shrink-0">
|
||||
<SlidersHorizontal className="size-3.5" />
|
||||
{translate('components.workspace.cleanup.browse.filters', 'Filters')}
|
||||
{activeFacetGroupCount > 0 ? (
|
||||
<span className="ml-1 rounded-full bg-accent px-1.5 text-[11px] tabular-nums text-accent-foreground">
|
||||
{activeFacetGroupCount}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={6} className="w-[320px] p-0">
|
||||
<ScrollArea className="max-h-[420px]">
|
||||
<WorkspaceCleanupLifecycleFacets {...facetProps} />
|
||||
<WorkspaceCleanupGitReviewFacets {...facetProps} />
|
||||
</ScrollArea>
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border px-3 py-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{translate(
|
||||
'components.workspace.cleanup.browse.showingCount',
|
||||
'Showing {{value0}} of {{value1}}',
|
||||
{ value0: matchedCount, value1: totalCount }
|
||||
)}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" disabled={!hasActiveFilters} onClick={onClearFilters}>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{translate('components.workspace.cleanup.browse.clearFilters', 'Clear filters')}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{translate(
|
||||
'components.workspace.cleanup.browse.showingCount',
|
||||
'Showing {{value0}} of {{value1}}',
|
||||
{ value0: matchedCount, value1: totalCount }
|
||||
)}
|
||||
</span>
|
||||
|
||||
{gitEvidence.pendingCount > 0 ? (
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{translate(
|
||||
'components.workspace.cleanup.browse.checkingGit',
|
||||
'Checking git status: {{value0}} left',
|
||||
{ value0: gitEvidence.pendingCount }
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{sizeScan.measuredCount === 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={sizeScan.scanning}
|
||||
onClick={sizeScan.onRun}
|
||||
>
|
||||
{sizeScan.scanning ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<HardDrive className="size-3.5" />
|
||||
)}
|
||||
{translate('components.workspace.cleanup.browse.measureSizes', 'Measure sizes')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import type { WorkspaceCleanupGitState } from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import type {
|
||||
WorkspaceCleanupContextFilter,
|
||||
WorkspaceCleanupFilters,
|
||||
@@ -94,20 +95,35 @@ export function sortWorkspaceCleanupCandidates(
|
||||
})
|
||||
}
|
||||
|
||||
const GIT_STATE_LABELS: Record<WorkspaceCleanupGitState, string> = {
|
||||
unpushed: 'Unpushed',
|
||||
unknown: 'Unknown',
|
||||
clean: 'Clean',
|
||||
dirty: 'Dirty'
|
||||
}
|
||||
|
||||
export function getWorkspaceCleanupGitLabel(candidate: WorkspaceCleanupCandidate): string {
|
||||
return GIT_STATE_LABELS[getWorkspaceCleanupGitState(candidate)]
|
||||
}
|
||||
|
||||
/** Unpushed outranks unknown: a lost commit is worse than an unreadable status. */
|
||||
export function getWorkspaceCleanupGitState(
|
||||
candidate: WorkspaceCleanupCandidate
|
||||
): WorkspaceCleanupGitState {
|
||||
if (hasUnpushedCommits(candidate)) {
|
||||
return 'Unpushed'
|
||||
return 'unpushed'
|
||||
}
|
||||
if (isGitStatusUnknown(candidate)) {
|
||||
return 'Unknown'
|
||||
return 'unknown'
|
||||
}
|
||||
if (candidate.git.clean === true) {
|
||||
return 'Clean'
|
||||
return 'clean'
|
||||
}
|
||||
if (candidate.git.clean === false) {
|
||||
return 'Dirty'
|
||||
}
|
||||
return 'Unknown'
|
||||
return candidate.git.clean === false ? 'dirty' : 'unknown'
|
||||
}
|
||||
|
||||
export function hasWorkspaceCleanupUnpushedCommits(candidate: WorkspaceCleanupCandidate): boolean {
|
||||
return hasUnpushedCommits(candidate)
|
||||
}
|
||||
|
||||
function compareWorkspaceCleanupCandidates(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { ReactNode } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { applyWorkspaceCleanupPreset } from '../../../../shared/workspace-cleanup-preset-state'
|
||||
import { WORKSPACE_CLEANUP_BUILT_IN_PRESETS } from '../../../../shared/workspace-cleanup-presets'
|
||||
import { CandidateRow } from './workspace-cleanup-candidate-row'
|
||||
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
|
||||
import { FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture'
|
||||
import { runWorkspaceCleanupQuery } from './workspace-cleanup-query'
|
||||
import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets'
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: () => null,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
// One row per bucket the retired tab partition used to hide behind a tab.
|
||||
const FLEET: WorkspaceCleanupFacets[] = [
|
||||
makeNamedFacets('ready-one', { candidate: { tier: 'ready' }, sizeBytes: 4 * 1024 * 1024 }),
|
||||
makeNamedFacets('needs-review', { candidate: { tier: 'review' } }),
|
||||
makeNamedFacets('protected-one', {
|
||||
candidate: { tier: 'protected', blockers: ['pinned'], selectedByDefault: false }
|
||||
}),
|
||||
makeNamedFacets('ignored-one', { dismissed: true })
|
||||
]
|
||||
|
||||
function renderRows(rows: readonly WorkspaceCleanupFacets[]): void {
|
||||
act(() =>
|
||||
root?.render(
|
||||
<WorkspaceCleanupCandidateList
|
||||
rows={rows}
|
||||
scrollElement={null}
|
||||
renderRow={(row, index) => (
|
||||
<CandidateRow
|
||||
key={row.worktreeId}
|
||||
candidate={row.candidate}
|
||||
reviewInfo={row.review}
|
||||
expanded={false}
|
||||
last={index === rows.length - 1}
|
||||
lastActivityLabel="40d ago"
|
||||
sizeLabel={row.sizeBytes === null ? null : '4.00 MB'}
|
||||
selected={false}
|
||||
onIgnore={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onToggleExpanded={vi.fn()}
|
||||
onToggleSelected={vi.fn()}
|
||||
onView={vi.fn()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function renderedNames(): string[] {
|
||||
// Buttons share `text-sm font-medium`, so scope to the row's name span.
|
||||
return [...(container?.querySelectorAll('span.truncate.text-sm.font-medium') ?? [])].map(
|
||||
(node) => node.textContent ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
function query(presetId: string) {
|
||||
const preset = WORKSPACE_CLEANUP_BUILT_IN_PRESETS.find((entry) => entry.id === presetId)
|
||||
if (!preset) {
|
||||
throw new Error(`missing preset ${presetId}`)
|
||||
}
|
||||
return runWorkspaceCleanupQuery(FLEET, applyWorkspaceCleanupPreset(preset), FACET_NOW)
|
||||
}
|
||||
|
||||
describe('workspace cleanup flat list', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
it('renders every tier in one list rather than per-tab groups', () => {
|
||||
const result = query('all')
|
||||
|
||||
renderRows(result.rows)
|
||||
|
||||
expect(result.totalCount).toBe(FLEET.length)
|
||||
expect(result.matchedCount).toBe(FLEET.length)
|
||||
expect(renderedNames()).toEqual(
|
||||
expect.arrayContaining(['ready-one', 'needs-review', 'protected-one', 'ignored-one'])
|
||||
)
|
||||
})
|
||||
|
||||
it('switches the visible rows when a preset is applied', () => {
|
||||
const suggested = query('suggested')
|
||||
renderRows(suggested.rows)
|
||||
expect(renderedNames()).toEqual(['ready-one'])
|
||||
|
||||
const ignored = query('ignored')
|
||||
renderRows(ignored.rows)
|
||||
expect(renderedNames()).toEqual(['ignored-one'])
|
||||
|
||||
const protectedRows = query('protected')
|
||||
renderRows(protectedRows.rows)
|
||||
expect(renderedNames()).toEqual(['protected-one'])
|
||||
})
|
||||
|
||||
it('reports only the rows the user could actually queue for deletion', () => {
|
||||
expect(query('all').selectableWorktreeIds).not.toContain('repo-1::/repo/protected-one')
|
||||
})
|
||||
|
||||
it('shows a measured size on the row and nothing when the space scan never ran', () => {
|
||||
renderRows(query('all').rows)
|
||||
|
||||
expect(container?.textContent).toContain('4.00 MB')
|
||||
const sizedRows = [...(container?.querySelectorAll('[aria-label^="Size on disk"]') ?? [])]
|
||||
expect(sizedRows).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useNow } from '@/components/dashboard/useNow'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup'
|
||||
import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time'
|
||||
|
||||
/**
|
||||
* Fixed-slot freshness line: "Updated 2h ago" for the data on screen plus a
|
||||
* non-blocking "Refreshing" affordance while a background rescan streams.
|
||||
*/
|
||||
export function WorkspaceCleanupFreshness({
|
||||
scannedAt,
|
||||
refreshing,
|
||||
progress
|
||||
}: {
|
||||
scannedAt: number | null
|
||||
refreshing: boolean
|
||||
progress: WorkspaceCleanupScanProgress | null
|
||||
}): React.JSX.Element | null {
|
||||
const now = useNow(30_000)
|
||||
if (scannedAt === null && !refreshing) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{scannedAt !== null ? (
|
||||
<span className="truncate">
|
||||
{translate('components.workspace.cleanup.browse.updatedAgo', 'Updated {{value0}}', {
|
||||
value0: formatWorkspaceCleanupRelativeTime(scannedAt, now).toLowerCase()
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
{refreshing ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Loader2 className="size-3 animate-spin" aria-hidden="true" />
|
||||
{formatWorkspaceCleanupRefreshingLabel(progress)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function formatWorkspaceCleanupRefreshingLabel(
|
||||
progress: WorkspaceCleanupScanProgress | null
|
||||
): string {
|
||||
if (!progress || progress.totalWorktreeCount === 0) {
|
||||
return translate('components.workspace.cleanup.browse.refreshing', 'Refreshing…')
|
||||
}
|
||||
return translate(
|
||||
'components.workspace.cleanup.browse.refreshingProgress',
|
||||
'Refreshing {{value0}}/{{value1}}',
|
||||
{ value0: progress.scannedWorktreeCount, value1: progress.totalWorktreeCount }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture'
|
||||
import {
|
||||
applyWorkspaceCleanupGitEvidence,
|
||||
hasWorkspaceCleanupGitEvidence,
|
||||
needsWorkspaceCleanupGitEvidence,
|
||||
selectWorkspaceCleanupGitEvidenceTargets
|
||||
} from './workspace-cleanup-git-evidence'
|
||||
|
||||
const CLEAN_SORT = { field: 'name', direction: 'asc' } as const
|
||||
|
||||
function deferredCandidate(worktreeId: string) {
|
||||
return makeFacetCandidate({
|
||||
worktreeId,
|
||||
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }
|
||||
})
|
||||
}
|
||||
|
||||
describe('needsWorkspaceCleanupGitEvidence', () => {
|
||||
it('stays off for a filter and sort that never read git', () => {
|
||||
expect(
|
||||
needsWorkspaceCleanupGitEvidence(createDefaultWorkspaceCleanupFilterState(), CLEAN_SORT)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'git state',
|
||||
(f: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>) => {
|
||||
f.git.states = ['dirty']
|
||||
}
|
||||
],
|
||||
[
|
||||
'ahead threshold',
|
||||
(f: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>) => {
|
||||
f.git.minAhead = 1
|
||||
}
|
||||
],
|
||||
[
|
||||
'behind threshold',
|
||||
(f: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>) => {
|
||||
f.git.minBehind = 1
|
||||
}
|
||||
],
|
||||
[
|
||||
'git-derived blocker',
|
||||
(f: ReturnType<typeof createDefaultWorkspaceCleanupFilterState>) => {
|
||||
f.safety.blockers = ['unpushed-commits']
|
||||
}
|
||||
]
|
||||
])('turns on for a %s filter', (_label, mutate) => {
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
mutate(filters)
|
||||
expect(needsWorkspaceCleanupGitEvidence(filters, CLEAN_SORT)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['git', 'ahead', 'behind'] as const)('turns on for the %s sort', (field) => {
|
||||
expect(
|
||||
needsWorkspaceCleanupGitEvidence(createDefaultWorkspaceCleanupFilterState(), {
|
||||
field,
|
||||
direction: 'asc'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a blocker filter that does not depend on git', () => {
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
filters.safety.blockers = ['pinned']
|
||||
expect(needsWorkspaceCleanupGitEvidence(filters, CLEAN_SORT)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectWorkspaceCleanupGitEvidenceTargets', () => {
|
||||
it('only targets rows the host deferred', () => {
|
||||
const targets = selectWorkspaceCleanupGitEvidenceTargets([
|
||||
deferredCandidate('a'),
|
||||
makeFacetCandidate({ worktreeId: 'b' })
|
||||
])
|
||||
expect(targets).toEqual(['a'])
|
||||
expect(hasWorkspaceCleanupGitEvidence(makeFacetCandidate({ worktreeId: 'b' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('skips rows already attempted and caps the batch', () => {
|
||||
const candidates = ['a', 'b', 'c'].map(deferredCandidate)
|
||||
expect(
|
||||
selectWorkspaceCleanupGitEvidenceTargets(candidates, {
|
||||
resolvedWorktreeIds: new Set(['a'])
|
||||
})
|
||||
).toEqual(['b', 'c'])
|
||||
expect(selectWorkspaceCleanupGitEvidenceTargets(candidates, { maxTargets: 2 })).toEqual([
|
||||
'a',
|
||||
'b'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyWorkspaceCleanupGitEvidence', () => {
|
||||
it('replaces the deferred row with the focused re-scan result', () => {
|
||||
const deferred = deferredCandidate('a')
|
||||
const refreshed = makeFacetCandidate({
|
||||
worktreeId: 'a',
|
||||
git: { clean: false, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 5 }
|
||||
})
|
||||
const [row] = applyWorkspaceCleanupGitEvidence([deferred], new Map([['a', refreshed]]))
|
||||
expect(row.git.clean).toBe(false)
|
||||
expect(row.git.checkedAt).toBe(5)
|
||||
})
|
||||
|
||||
it('returns the input untouched when nothing was re-scanned', () => {
|
||||
const candidates = [deferredCandidate('a')]
|
||||
expect(applyWorkspaceCleanupGitEvidence(candidates, new Map())).toBe(candidates)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
WorkspaceCleanupBlocker,
|
||||
WorkspaceCleanupCandidate
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import type {
|
||||
WorkspaceCleanupFilterState,
|
||||
WorkspaceCleanupSortField,
|
||||
WorkspaceCleanupSortState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
|
||||
/**
|
||||
* The host defers git evidence for rows with no inactivity reason, so their
|
||||
* `git.checkedAt` is null and the facet layer reads them as `unknown`. A
|
||||
* git-dependent filter or sort would then quietly mislabel most of the list,
|
||||
* so the dialog re-scans those rows on demand before trusting the answer.
|
||||
*/
|
||||
export function hasWorkspaceCleanupGitEvidence(candidate: WorkspaceCleanupCandidate): boolean {
|
||||
return candidate.git.checkedAt !== null
|
||||
}
|
||||
|
||||
const GIT_SORT_FIELDS: ReadonlySet<WorkspaceCleanupSortField> = new Set(['git', 'ahead', 'behind'])
|
||||
|
||||
const GIT_DERIVED_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocker> = new Set([
|
||||
'dirty-files',
|
||||
'unpushed-commits',
|
||||
'git-status-error',
|
||||
'unknown-base'
|
||||
])
|
||||
|
||||
/** Bounds how many git processes one filter toggle can spawn across a huge fleet. */
|
||||
export const WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS = 500
|
||||
export const WORKSPACE_CLEANUP_GIT_EVIDENCE_CONCURRENCY = 4
|
||||
|
||||
export function needsWorkspaceCleanupGitEvidence(
|
||||
filters: WorkspaceCleanupFilterState,
|
||||
sort: WorkspaceCleanupSortState
|
||||
): boolean {
|
||||
return (
|
||||
filters.git.states.length > 0 ||
|
||||
filters.git.minAhead !== null ||
|
||||
filters.git.minBehind !== null ||
|
||||
filters.safety.blockers.some((blocker) => GIT_DERIVED_BLOCKERS.has(blocker)) ||
|
||||
GIT_SORT_FIELDS.has(sort.field)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids still missing git evidence, capped and in list order so the rows the user
|
||||
* is looking at resolve first.
|
||||
*/
|
||||
export function selectWorkspaceCleanupGitEvidenceTargets(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
options: {
|
||||
resolvedWorktreeIds?: ReadonlySet<string>
|
||||
maxTargets?: number
|
||||
} = {}
|
||||
): string[] {
|
||||
const resolved = options.resolvedWorktreeIds ?? new Set<string>()
|
||||
const max = options.maxTargets ?? WORKSPACE_CLEANUP_GIT_EVIDENCE_MAX_TARGETS
|
||||
const targets: string[] = []
|
||||
for (const candidate of candidates) {
|
||||
if (targets.length >= max) {
|
||||
break
|
||||
}
|
||||
if (!hasWorkspaceCleanupGitEvidence(candidate) && !resolved.has(candidate.worktreeId)) {
|
||||
targets.push(candidate.worktreeId)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
/** Focused re-scan results win over the deferred broad-scan row. */
|
||||
export function applyWorkspaceCleanupGitEvidence(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
evidenceByWorktreeId: ReadonlyMap<string, WorkspaceCleanupCandidate>
|
||||
): readonly WorkspaceCleanupCandidate[] {
|
||||
if (evidenceByWorktreeId.size === 0) {
|
||||
return candidates
|
||||
}
|
||||
return candidates.map((candidate) => evidenceByWorktreeId.get(candidate.worktreeId) ?? candidate)
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import React from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_BLOCKER_MODE_VALUES,
|
||||
WORKSPACE_CLEANUP_BLOCKER_VALUES,
|
||||
WORKSPACE_CLEANUP_GIT_STATE_VALUES,
|
||||
WORKSPACE_CLEANUP_PRESENCE_VALUES,
|
||||
WORKSPACE_CLEANUP_REVIEW_STATE_VALUES,
|
||||
WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES,
|
||||
WORKSPACE_CLEANUP_TIER_VALUES,
|
||||
WORKSPACE_CLEANUP_TRI_STATE_VALUES
|
||||
} from '../../../../shared/workspace-cleanup-facet-rankings'
|
||||
import { getWorkspaceCleanupBlockerLabel } from './workspace-cleanup-candidate-labels'
|
||||
import {
|
||||
FacetCheckbox,
|
||||
FacetChoice,
|
||||
FacetNumberField,
|
||||
FacetSection,
|
||||
FacetTextField,
|
||||
FacetToggleList
|
||||
} from './workspace-cleanup-facet-controls'
|
||||
import {
|
||||
getWorkspaceCleanupGitStateLabel,
|
||||
getWorkspaceCleanupPresenceLabel,
|
||||
getWorkspaceCleanupReviewProviderLabel,
|
||||
getWorkspaceCleanupReviewStateLabel,
|
||||
getWorkspaceCleanupTicketSourceLabel,
|
||||
getWorkspaceCleanupTierLabel,
|
||||
getWorkspaceCleanupTriStateLabel
|
||||
} from './workspace-cleanup-facet-labels'
|
||||
import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-panel-model'
|
||||
|
||||
/** Git, review/PR, tickets, location/host, and safety blockers. */
|
||||
export function WorkspaceCleanupGitReviewFacets({
|
||||
filters,
|
||||
counts,
|
||||
totalCount,
|
||||
options,
|
||||
onPatch
|
||||
}: WorkspaceCleanupFacetGroupProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<FacetSection
|
||||
title={translate('components.workspace.cleanup.browse.facet.git', 'Git')}
|
||||
matchCount={counts.git}
|
||||
totalCount={totalCount}
|
||||
>
|
||||
<FacetToggleList
|
||||
values={WORKSPACE_CLEANUP_GIT_STATE_VALUES}
|
||||
selected={filters.git.states}
|
||||
getLabel={getWorkspaceCleanupGitStateLabel}
|
||||
onChange={(states) => onPatch('git', { states })}
|
||||
/>
|
||||
<FacetNumberField
|
||||
label={translate('components.workspace.cleanup.browse.minAhead', 'Commits ahead ≥')}
|
||||
value={filters.git.minAhead}
|
||||
onChange={(minAhead) => onPatch('git', { minAhead })}
|
||||
/>
|
||||
<FacetNumberField
|
||||
label={translate('components.workspace.cleanup.browse.minBehind', 'Commits behind ≥')}
|
||||
value={filters.git.minBehind}
|
||||
onChange={(minBehind) => onPatch('git', { minBehind })}
|
||||
/>
|
||||
<FacetTextField
|
||||
label={translate('components.workspace.cleanup.browse.branchQuery', 'Branch contains')}
|
||||
value={filters.git.branchQuery}
|
||||
onChange={(branchQuery) => onPatch('git', { branchQuery })}
|
||||
/>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.prunable', 'Prunable')}
|
||||
value={filters.git.prunable}
|
||||
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
|
||||
getLabel={getWorkspaceCleanupTriStateLabel}
|
||||
onChange={(prunable) => onPatch('git', { prunable })}
|
||||
/>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.locked', 'Locked')}
|
||||
value={filters.git.locked}
|
||||
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
|
||||
getLabel={getWorkspaceCleanupTriStateLabel}
|
||||
onChange={(locked) => onPatch('git', { locked })}
|
||||
/>
|
||||
</FacetSection>
|
||||
|
||||
<FacetSection
|
||||
title={translate('components.workspace.cleanup.browse.facet.review', 'Review')}
|
||||
matchCount={counts.review}
|
||||
totalCount={totalCount}
|
||||
>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.reviewPresence', 'PR / MR')}
|
||||
value={filters.review.presence}
|
||||
options={WORKSPACE_CLEANUP_PRESENCE_VALUES}
|
||||
getLabel={getWorkspaceCleanupPresenceLabel}
|
||||
onChange={(presence) => onPatch('review', { presence })}
|
||||
/>
|
||||
<FacetToggleList
|
||||
values={WORKSPACE_CLEANUP_REVIEW_STATE_VALUES}
|
||||
selected={filters.review.states}
|
||||
getLabel={getWorkspaceCleanupReviewStateLabel}
|
||||
onChange={(states) => onPatch('review', { states })}
|
||||
/>
|
||||
{options.reviewProviders.length > 0 ? (
|
||||
<FacetToggleList
|
||||
label={translate('components.workspace.cleanup.browse.reviewProvider', 'Provider')}
|
||||
values={options.reviewProviders}
|
||||
selected={filters.review.providers}
|
||||
getLabel={getWorkspaceCleanupReviewProviderLabel}
|
||||
onChange={(providers) => onPatch('review', { providers })}
|
||||
/>
|
||||
) : null}
|
||||
</FacetSection>
|
||||
|
||||
<FacetSection
|
||||
title={translate('components.workspace.cleanup.browse.facet.ticket', 'Tickets')}
|
||||
matchCount={counts.ticket}
|
||||
totalCount={totalCount}
|
||||
>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.ticketPresence', 'Linked ticket')}
|
||||
value={filters.ticket.presence}
|
||||
options={WORKSPACE_CLEANUP_PRESENCE_VALUES}
|
||||
getLabel={getWorkspaceCleanupPresenceLabel}
|
||||
onChange={(presence) => onPatch('ticket', { presence })}
|
||||
/>
|
||||
<FacetToggleList
|
||||
values={WORKSPACE_CLEANUP_TICKET_SOURCE_VALUES}
|
||||
selected={filters.ticket.sources}
|
||||
getLabel={getWorkspaceCleanupTicketSourceLabel}
|
||||
onChange={(sources) => onPatch('ticket', { sources })}
|
||||
/>
|
||||
</FacetSection>
|
||||
|
||||
<FacetSection
|
||||
title={translate('components.workspace.cleanup.browse.facet.location', 'Location')}
|
||||
matchCount={counts.location}
|
||||
totalCount={totalCount}
|
||||
>
|
||||
<FacetToggleList
|
||||
label={translate('components.workspace.cleanup.browse.host', 'Host')}
|
||||
values={options.hostIds}
|
||||
selected={filters.location.hostIds}
|
||||
getLabel={getExecutionHostLabel}
|
||||
onChange={(hostIds) => onPatch('location', { hostIds })}
|
||||
/>
|
||||
{options.repos.length > 1 ? (
|
||||
<FacetToggleList
|
||||
label={translate('components.workspace.cleanup.browse.repo', 'Repository')}
|
||||
values={options.repos.map((repo) => repo.id)}
|
||||
selected={filters.location.repoIds}
|
||||
getLabel={(id) => options.repos.find((repo) => repo.id === id)?.label ?? id}
|
||||
onChange={(repoIds) => onPatch('location', { repoIds })}
|
||||
/>
|
||||
) : null}
|
||||
<FacetTextField
|
||||
label={translate('components.workspace.cleanup.browse.pathPrefix', 'Path starts with')}
|
||||
value={filters.location.pathPrefix}
|
||||
onChange={(pathPrefix) => onPatch('location', { pathPrefix })}
|
||||
/>
|
||||
</FacetSection>
|
||||
|
||||
<FacetSection
|
||||
title={translate('components.workspace.cleanup.browse.facet.safety', 'Safety')}
|
||||
matchCount={counts.safety}
|
||||
totalCount={totalCount}
|
||||
>
|
||||
<FacetToggleList
|
||||
label={translate('components.workspace.cleanup.browse.tierField', 'Cleanup tier')}
|
||||
values={WORKSPACE_CLEANUP_TIER_VALUES}
|
||||
selected={filters.safety.tiers}
|
||||
getLabel={getWorkspaceCleanupTierLabel}
|
||||
onChange={(tiers) => onPatch('safety', { tiers })}
|
||||
/>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.blockerMode', 'Blocker match')}
|
||||
value={filters.safety.blockerMode}
|
||||
options={WORKSPACE_CLEANUP_BLOCKER_MODE_VALUES}
|
||||
onChange={(blockerMode) => onPatch('safety', { blockerMode })}
|
||||
/>
|
||||
<FacetToggleList
|
||||
label={translate('components.workspace.cleanup.browse.blockers', 'Blockers')}
|
||||
values={WORKSPACE_CLEANUP_BLOCKER_VALUES}
|
||||
selected={filters.safety.blockers}
|
||||
getLabel={getWorkspaceCleanupBlockerLabel}
|
||||
onChange={(blockers) => onPatch('safety', { blockers })}
|
||||
/>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.dismissed', 'Ignored')}
|
||||
value={filters.safety.dismissed}
|
||||
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
|
||||
getLabel={getWorkspaceCleanupTriStateLabel}
|
||||
onChange={(dismissed) => onPatch('safety', { dismissed })}
|
||||
/>
|
||||
<FacetCheckbox
|
||||
label={translate(
|
||||
'components.workspace.cleanup.browse.selectableOnly',
|
||||
'Only workspaces I can delete now'
|
||||
)}
|
||||
checked={filters.safety.selectableOnly}
|
||||
onChange={(selectableOnly) => onPatch('safety', { selectableOnly })}
|
||||
/>
|
||||
</FacetSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import React from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
WORKSPACE_CLEANUP_AGENT_STATE_VALUES,
|
||||
WORKSPACE_CLEANUP_IDLE_SIGNAL_VALUES,
|
||||
WORKSPACE_CLEANUP_PRESENCE_VALUES,
|
||||
WORKSPACE_CLEANUP_TRI_STATE_VALUES
|
||||
} from '../../../../shared/workspace-cleanup-facet-rankings'
|
||||
import {
|
||||
FacetCheckbox,
|
||||
FacetChoice,
|
||||
FacetNumberField,
|
||||
FacetSection,
|
||||
FacetToggleList
|
||||
} from './workspace-cleanup-facet-controls'
|
||||
import {
|
||||
getWorkspaceCleanupAgentStateLabel,
|
||||
getWorkspaceCleanupIdleSignalLabel,
|
||||
getWorkspaceCleanupPresenceLabel,
|
||||
getWorkspaceCleanupTriStateLabel
|
||||
} from './workspace-cleanup-facet-labels'
|
||||
import {
|
||||
fromMegabytes,
|
||||
toMegabytes,
|
||||
type WorkspaceCleanupFacetGroupProps
|
||||
} from './workspace-cleanup-facet-panel-model'
|
||||
|
||||
/** Activity, size, workspace status, agent state, and local context. */
|
||||
export function WorkspaceCleanupLifecycleFacets({
|
||||
filters,
|
||||
counts,
|
||||
totalCount,
|
||||
options,
|
||||
onPatch
|
||||
}: WorkspaceCleanupFacetGroupProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<FacetSection
|
||||
title={translate('components.workspace.cleanup.browse.facet.activity', 'Activity')}
|
||||
matchCount={counts.activity}
|
||||
totalCount={totalCount}
|
||||
>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.idleSignalField', 'Idle signal')}
|
||||
value={filters.activity.idleSignal}
|
||||
options={WORKSPACE_CLEANUP_IDLE_SIGNAL_VALUES}
|
||||
getLabel={getWorkspaceCleanupIdleSignalLabel}
|
||||
onChange={(idleSignal) => onPatch('activity', { idleSignal })}
|
||||
/>
|
||||
<FacetNumberField
|
||||
label={translate('components.workspace.cleanup.browse.idleMinDays', 'Idle for at least')}
|
||||
value={filters.activity.idleMinDays}
|
||||
placeholder="30"
|
||||
suffix={translate('components.workspace.cleanup.browse.days', 'days')}
|
||||
onChange={(idleMinDays) => onPatch('activity', { idleMinDays })}
|
||||
/>
|
||||
<FacetCheckbox
|
||||
label={translate('components.workspace.cleanup.browse.neverVisited', 'Never opened')}
|
||||
checked={filters.activity.neverVisited}
|
||||
onChange={(neverVisited) => onPatch('activity', { neverVisited })}
|
||||
/>
|
||||
</FacetSection>
|
||||
|
||||
<FacetSection
|
||||
title={translate('components.workspace.cleanup.browse.facet.size', 'Size on disk')}
|
||||
matchCount={counts.size}
|
||||
totalCount={totalCount}
|
||||
>
|
||||
<FacetNumberField
|
||||
label={translate('components.workspace.cleanup.browse.minSize', 'At least')}
|
||||
value={toMegabytes(filters.size.minBytes)}
|
||||
placeholder="0"
|
||||
suffix="MB"
|
||||
onChange={(megabytes) => onPatch('size', { minBytes: fromMegabytes(megabytes) })}
|
||||
/>
|
||||
<FacetNumberField
|
||||
label={translate('components.workspace.cleanup.browse.maxSize', 'At most')}
|
||||
value={toMegabytes(filters.size.maxBytes)}
|
||||
suffix="MB"
|
||||
onChange={(megabytes) => onPatch('size', { maxBytes: fromMegabytes(megabytes) })}
|
||||
/>
|
||||
<FacetCheckbox
|
||||
label={translate(
|
||||
'components.workspace.cleanup.browse.includeUnsized',
|
||||
'Include unmeasured workspaces'
|
||||
)}
|
||||
checked={filters.size.includeUnsized}
|
||||
onChange={(includeUnsized) => onPatch('size', { includeUnsized })}
|
||||
/>
|
||||
</FacetSection>
|
||||
|
||||
<FacetSection
|
||||
title={translate('components.workspace.cleanup.browse.facet.status', 'Workspace status')}
|
||||
matchCount={counts.status}
|
||||
totalCount={totalCount}
|
||||
>
|
||||
{options.workspaceStatuses.length > 0 ? (
|
||||
<FacetToggleList
|
||||
values={options.workspaceStatuses.map((status) => status.id)}
|
||||
selected={filters.status.workspaceStatuses}
|
||||
getLabel={(id) =>
|
||||
options.workspaceStatuses.find((status) => status.id === id)?.label ?? id
|
||||
}
|
||||
onChange={(workspaceStatuses) => onPatch('status', { workspaceStatuses })}
|
||||
/>
|
||||
) : null}
|
||||
<FacetCheckbox
|
||||
label={translate(
|
||||
'components.workspace.cleanup.browse.matchStatusless',
|
||||
'Include workspaces with no status'
|
||||
)}
|
||||
checked={filters.status.matchStatusless}
|
||||
onChange={(matchStatusless) => onPatch('status', { matchStatusless })}
|
||||
/>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.archived', 'Archived')}
|
||||
value={filters.status.archived}
|
||||
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
|
||||
getLabel={getWorkspaceCleanupTriStateLabel}
|
||||
onChange={(archived) => onPatch('status', { archived })}
|
||||
/>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.pinned', 'Pinned')}
|
||||
value={filters.status.pinned}
|
||||
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
|
||||
getLabel={getWorkspaceCleanupTriStateLabel}
|
||||
onChange={(pinned) => onPatch('status', { pinned })}
|
||||
/>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.unread', 'Unread')}
|
||||
value={filters.status.unread}
|
||||
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
|
||||
getLabel={getWorkspaceCleanupTriStateLabel}
|
||||
onChange={(unread) => onPatch('status', { unread })}
|
||||
/>
|
||||
<FacetChoice
|
||||
label={translate('components.workspace.cleanup.browse.comment', 'Has comment')}
|
||||
value={filters.status.comment}
|
||||
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
|
||||
getLabel={getWorkspaceCleanupTriStateLabel}
|
||||
onChange={(comment) => onPatch('status', { comment })}
|
||||
/>
|
||||
</FacetSection>
|
||||
|
||||
<FacetSection
|
||||
title={translate('components.workspace.cleanup.browse.facet.agent', 'Agent')}
|
||||
matchCount={counts.agent}
|
||||
totalCount={totalCount}
|
||||
>
|
||||
<FacetToggleList
|
||||
values={WORKSPACE_CLEANUP_AGENT_STATE_VALUES}
|
||||
selected={filters.agent.states}
|
||||
getLabel={getWorkspaceCleanupAgentStateLabel}
|
||||
onChange={(states) => onPatch('agent', { states })}
|
||||
/>
|
||||
<FacetChoice
|
||||
label={translate(
|
||||
'components.workspace.cleanup.browse.retainedDoneAgents',
|
||||
'Finished agent transcripts'
|
||||
)}
|
||||
value={filters.agent.retainedDoneAgents}
|
||||
options={WORKSPACE_CLEANUP_TRI_STATE_VALUES}
|
||||
getLabel={getWorkspaceCleanupTriStateLabel}
|
||||
onChange={(retainedDoneAgents) => onPatch('agent', { retainedDoneAgents })}
|
||||
/>
|
||||
</FacetSection>
|
||||
|
||||
<FacetSection
|
||||
title={translate('components.workspace.cleanup.browse.facet.context', 'Local context')}
|
||||
matchCount={counts.context}
|
||||
totalCount={totalCount}
|
||||
>
|
||||
<FacetChoice
|
||||
label={translate(
|
||||
'components.workspace.cleanup.browse.contextPresence',
|
||||
'Open tabs, terminals, comments'
|
||||
)}
|
||||
value={filters.context.presence}
|
||||
options={WORKSPACE_CLEANUP_PRESENCE_VALUES}
|
||||
getLabel={getWorkspaceCleanupPresenceLabel}
|
||||
onChange={(presence) => onPatch('context', { presence })}
|
||||
/>
|
||||
<FacetCheckbox
|
||||
label={translate(
|
||||
'components.workspace.cleanup.browse.completelyEmpty',
|
||||
'Nothing left to lose'
|
||||
)}
|
||||
checked={filters.context.completelyEmpty}
|
||||
onChange={(completelyEmpty) => onPatch('context', { completelyEmpty })}
|
||||
/>
|
||||
</FacetSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { ReactNode } from 'react'
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WORKSPACE_CLEANUP_BUILT_IN_PRESETS } from '../../../../shared/workspace-cleanup-presets'
|
||||
import { WorkspaceCleanupPresetChips } from './workspace-cleanup-preset-chips'
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: () => null,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function render(node: ReactNode): void {
|
||||
act(() => root?.render(node))
|
||||
}
|
||||
|
||||
function chip(presetId: string): HTMLButtonElement | null {
|
||||
return container?.querySelector<HTMLButtonElement>(`[data-preset-id="${presetId}"]`) ?? null
|
||||
}
|
||||
|
||||
describe('WorkspaceCleanupPresetChips', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
it('renders every preset as a chip instead of a fixed tab set', () => {
|
||||
render(
|
||||
<WorkspaceCleanupPresetChips
|
||||
presets={WORKSPACE_CLEANUP_BUILT_IN_PRESETS}
|
||||
matchedPresetId="suggested"
|
||||
requestedPresetId="suggested"
|
||||
hasActiveFilters={false}
|
||||
onApplyPreset={vi.fn()}
|
||||
onClearFilters={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(container?.querySelectorAll('[data-preset-id]')).toHaveLength(
|
||||
WORKSPACE_CLEANUP_BUILT_IN_PRESETS.length
|
||||
)
|
||||
expect(chip('ignored')).not.toBeNull()
|
||||
expect(chip('all')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('marks the matched preset active and the edited one modified', () => {
|
||||
render(
|
||||
<WorkspaceCleanupPresetChips
|
||||
presets={WORKSPACE_CLEANUP_BUILT_IN_PRESETS}
|
||||
matchedPresetId={null}
|
||||
requestedPresetId="needs-review"
|
||||
hasActiveFilters
|
||||
onApplyPreset={vi.fn()}
|
||||
onClearFilters={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(chip('needs-review')?.getAttribute('data-preset-state')).toBe('modified')
|
||||
expect(chip('needs-review')?.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(chip('suggested')?.getAttribute('data-preset-state')).toBe('idle')
|
||||
})
|
||||
|
||||
it('applies the clicked preset', () => {
|
||||
const onApplyPreset = vi.fn()
|
||||
render(
|
||||
<WorkspaceCleanupPresetChips
|
||||
presets={WORKSPACE_CLEANUP_BUILT_IN_PRESETS}
|
||||
matchedPresetId="suggested"
|
||||
requestedPresetId="suggested"
|
||||
hasActiveFilters={false}
|
||||
onApplyPreset={onApplyPreset}
|
||||
onClearFilters={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() => chip('largest')?.click())
|
||||
|
||||
expect(onApplyPreset).toHaveBeenCalledWith(
|
||||
WORKSPACE_CLEANUP_BUILT_IN_PRESETS.find((preset) => preset.id === 'largest')
|
||||
)
|
||||
})
|
||||
|
||||
it('only offers clear filters once something is filtered', () => {
|
||||
const onClearFilters = vi.fn()
|
||||
render(
|
||||
<WorkspaceCleanupPresetChips
|
||||
presets={WORKSPACE_CLEANUP_BUILT_IN_PRESETS}
|
||||
matchedPresetId={null}
|
||||
requestedPresetId={null}
|
||||
hasActiveFilters={false}
|
||||
onApplyPreset={vi.fn()}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
)
|
||||
expect(container?.textContent).not.toContain('Clear filters')
|
||||
|
||||
render(
|
||||
<WorkspaceCleanupPresetChips
|
||||
presets={WORKSPACE_CLEANUP_BUILT_IN_PRESETS}
|
||||
matchedPresetId={null}
|
||||
requestedPresetId={null}
|
||||
hasActiveFilters
|
||||
onApplyPreset={vi.fn()}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
)
|
||||
const clearButton = [...(container?.querySelectorAll('button') ?? [])].find((button) =>
|
||||
button.textContent?.includes('Clear filters')
|
||||
)
|
||||
act(() => clearButton?.click())
|
||||
expect(onClearFilters).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupPreset } from '../../../../shared/workspace-cleanup-presets'
|
||||
|
||||
export function WorkspaceCleanupPresetChips({
|
||||
presets,
|
||||
matchedPresetId,
|
||||
requestedPresetId,
|
||||
hasActiveFilters,
|
||||
onApplyPreset,
|
||||
onClearFilters
|
||||
}: {
|
||||
presets: readonly WorkspaceCleanupPreset[]
|
||||
/** Preset the live filter state still equals, or null once it was edited. */
|
||||
matchedPresetId: string | null
|
||||
/** Preset the user last picked, even after edits, so the chip can read "modified". */
|
||||
requestedPresetId: string | null
|
||||
hasActiveFilters: boolean
|
||||
onApplyPreset: (preset: WorkspaceCleanupPreset) => void
|
||||
onClearFilters: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<ScrollArea className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 pb-1.5">
|
||||
{presets.map((preset) => {
|
||||
const active = matchedPresetId === preset.id
|
||||
const modified = !active && requestedPresetId === preset.id
|
||||
return (
|
||||
<Tooltip key={preset.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
data-preset-id={preset.id}
|
||||
data-preset-state={active ? 'active' : modified ? 'modified' : 'idle'}
|
||||
onClick={() => onApplyPreset(preset)}
|
||||
className={cn(
|
||||
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
active && 'bg-accent text-accent-foreground',
|
||||
modified && 'border-dashed text-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{preset.labelKey ? translate(preset.labelKey, preset.label) : preset.label}
|
||||
</span>
|
||||
{modified ? (
|
||||
<span className="text-[11px] font-normal text-muted-foreground">
|
||||
{translate('components.workspace.cleanup.browse.presetModified', 'edited')}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{preset.descriptionKey
|
||||
? translate(preset.descriptionKey, preset.description)
|
||||
: preset.description || preset.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
{hasActiveFilters ? (
|
||||
<Button variant="ghost" size="sm" className="shrink-0" onClick={onClearFilters}>
|
||||
<RotateCcw className="size-3.5" />
|
||||
{translate('components.workspace.cleanup.browse.clearFilters', 'Clear filters')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createDefaultWorkspaceCleanupBrowseState,
|
||||
normalizeWorkspaceCleanupBrowseState,
|
||||
WORKSPACE_CLEANUP_BROWSE_STATE_VERSION
|
||||
} from '../../../../shared/workspace-cleanup-browse-state'
|
||||
import { DAY, FACET_NOW, makeNamedFacets } from './workspace-cleanup-facet.test.fixture'
|
||||
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import {
|
||||
applyWorkspaceCleanupPreset,
|
||||
createWorkspaceCleanupCustomPreset,
|
||||
findWorkspaceCleanupPreset,
|
||||
isWorkspaceCleanupFilterStateEqual,
|
||||
listWorkspaceCleanupPresets,
|
||||
matchWorkspaceCleanupPresetId
|
||||
} from '../../../../shared/workspace-cleanup-preset-state'
|
||||
import { WORKSPACE_CLEANUP_BUILT_IN_PRESETS } from '../../../../shared/workspace-cleanup-presets'
|
||||
import { runWorkspaceCleanupQuery } from './workspace-cleanup-query'
|
||||
|
||||
function applyPreset(presetId: string, rows: ReturnType<typeof makeNamedFacets>[]): string[] {
|
||||
const preset = findWorkspaceCleanupPreset(presetId)
|
||||
expect(preset).not.toBeNull()
|
||||
return runWorkspaceCleanupQuery(rows, applyWorkspaceCleanupPreset(preset!), FACET_NOW).rows.map(
|
||||
(row) => row.displayName
|
||||
)
|
||||
}
|
||||
|
||||
const CORPUS = [
|
||||
makeNamedFacets('ready'),
|
||||
makeNamedFacets('needsReview', {
|
||||
candidate: {
|
||||
tier: 'review',
|
||||
blockers: ['dirty-files'],
|
||||
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 }
|
||||
}
|
||||
}),
|
||||
makeNamedFacets('locked', { candidate: { tier: 'protected', blockers: ['live-agent'] } }),
|
||||
makeNamedFacets('ignored', { dismissed: true }),
|
||||
makeNamedFacets('mergedClean', { review: { state: 'merged', provider: 'gitlab' } }),
|
||||
makeNamedFacets('visited', { lastVisitedAt: FACET_NOW - 5 * DAY }),
|
||||
makeNamedFacets('huge', { sizeBytes: 5_000, lastVisitedAt: FACET_NOW - 200 * DAY })
|
||||
]
|
||||
|
||||
describe('built-in presets', () => {
|
||||
it('exposes unique ids and i18n keys', () => {
|
||||
const ids = WORKSPACE_CLEANUP_BUILT_IN_PRESETS.map((preset) => preset.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
expect(ids).toEqual([
|
||||
'suggested',
|
||||
'needs-review',
|
||||
'protected',
|
||||
'ignored',
|
||||
'merged-review-clean',
|
||||
'never-opened',
|
||||
'largest',
|
||||
'stale-visits',
|
||||
'all'
|
||||
])
|
||||
for (const preset of WORKSPACE_CLEANUP_BUILT_IN_PRESETS) {
|
||||
expect(preset.source).toBe('built-in')
|
||||
expect(preset.labelKey).toBe(`components.workspace.cleanup.presets.${preset.id}.label`)
|
||||
}
|
||||
})
|
||||
|
||||
it('suggested keeps only selectable ready rows', () => {
|
||||
// Equal lastActivityAt across the corpus, so the name tie-break decides order.
|
||||
expect(applyPreset('suggested', CORPUS)).toEqual(['huge', 'mergedClean', 'ready', 'visited'])
|
||||
})
|
||||
|
||||
it('needs review and protected split the non-selectable rows', () => {
|
||||
expect(applyPreset('needs-review', CORPUS)).toEqual(['needsReview'])
|
||||
expect(applyPreset('protected', CORPUS)).toEqual(['locked'])
|
||||
})
|
||||
|
||||
it('ignored shows only dismissed rows', () => {
|
||||
expect(applyPreset('ignored', CORPUS)).toEqual(['ignored'])
|
||||
})
|
||||
|
||||
it('merged review & clean requires both a landed review and a clean tree', () => {
|
||||
expect(applyPreset('merged-review-clean', CORPUS)).toEqual(['mergedClean'])
|
||||
})
|
||||
|
||||
it('never opened ignores background activity', () => {
|
||||
expect(applyPreset('never-opened', CORPUS)).not.toContain('visited')
|
||||
expect(applyPreset('never-opened', CORPUS)).toContain('ready')
|
||||
})
|
||||
|
||||
it('largest drops unsized rows and sorts descending', () => {
|
||||
expect(applyPreset('largest', CORPUS)).toEqual(['huge'])
|
||||
const sized = [
|
||||
makeNamedFacets('small', { sizeBytes: 1 }),
|
||||
makeNamedFacets('big', { sizeBytes: 100 })
|
||||
]
|
||||
expect(applyPreset('largest', sized)).toEqual(['big', 'small'])
|
||||
})
|
||||
|
||||
it('stale visits uses the visit signal, not background activity', () => {
|
||||
expect(applyPreset('stale-visits', CORPUS)).toContain('huge')
|
||||
expect(applyPreset('stale-visits', CORPUS)).not.toContain('visited')
|
||||
})
|
||||
|
||||
it('all includes dismissed rows', () => {
|
||||
expect(applyPreset('all', CORPUS)).toContain('ignored')
|
||||
expect(applyPreset('all', CORPUS)).toHaveLength(CORPUS.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preset application', () => {
|
||||
it('deep clones so editing applied state never mutates the preset', () => {
|
||||
const preset = findWorkspaceCleanupPreset('suggested')!
|
||||
const applied = applyWorkspaceCleanupPreset(preset)
|
||||
applied.filters.safety.tiers.push('protected')
|
||||
applied.sort.direction = 'desc'
|
||||
expect(preset.filters.safety.tiers).toEqual(['ready'])
|
||||
expect(preset.sort.direction).toBe('asc')
|
||||
})
|
||||
|
||||
it('round-trips through preset matching', () => {
|
||||
for (const preset of WORKSPACE_CLEANUP_BUILT_IN_PRESETS) {
|
||||
expect(matchWorkspaceCleanupPresetId(applyWorkspaceCleanupPreset(preset))).toBe(preset.id)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports no preset once the user edits away from one', () => {
|
||||
const applied = applyWorkspaceCleanupPreset(findWorkspaceCleanupPreset('suggested')!)
|
||||
applied.filters.git.branchQuery = 'feature'
|
||||
expect(matchWorkspaceCleanupPresetId(applied)).toBeNull()
|
||||
})
|
||||
|
||||
it('compares multi-selects as sets, not ordered lists', () => {
|
||||
const left = createDefaultWorkspaceCleanupFilterState()
|
||||
const right = createDefaultWorkspaceCleanupFilterState()
|
||||
left.safety.blockers = ['pinned', 'live-agent']
|
||||
right.safety.blockers = ['live-agent', 'pinned']
|
||||
expect(isWorkspaceCleanupFilterStateEqual(left, right)).toBe(true)
|
||||
right.safety.blockers = ['pinned']
|
||||
expect(isWorkspaceCleanupFilterStateEqual(left, right)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom presets', () => {
|
||||
it('appends customs after built-ins and lets a custom id shadow a built-in', () => {
|
||||
const custom = createWorkspaceCleanupCustomPreset({
|
||||
id: 'mine',
|
||||
label: 'Mine',
|
||||
state: applyWorkspaceCleanupPreset(findWorkspaceCleanupPreset('all')!),
|
||||
createdAt: FACET_NOW
|
||||
})
|
||||
const listed = listWorkspaceCleanupPresets([custom])
|
||||
expect(listed.at(-1)?.id).toBe('mine')
|
||||
expect(listed.at(-1)?.source).toBe('custom')
|
||||
|
||||
const shadow = { ...custom, id: 'suggested', label: 'My suggested' }
|
||||
const shadowed = listWorkspaceCleanupPresets([shadow])
|
||||
expect(shadowed.filter((preset) => preset.id === 'suggested')).toHaveLength(1)
|
||||
expect(findWorkspaceCleanupPreset('suggested', [shadow])?.label).toBe('My suggested')
|
||||
})
|
||||
})
|
||||
|
||||
describe('persisted browse state', () => {
|
||||
it('defaults to the suggested preset', () => {
|
||||
const state = createDefaultWorkspaceCleanupBrowseState()
|
||||
expect(state.version).toBe(WORKSPACE_CLEANUP_BROWSE_STATE_VERSION)
|
||||
expect(state.activePresetId).toBe('suggested')
|
||||
expect(state.customPresets).toEqual([])
|
||||
})
|
||||
|
||||
it('degrades unknown, missing, and corrupt values instead of throwing', () => {
|
||||
expect(normalizeWorkspaceCleanupBrowseState(null)).toEqual(
|
||||
createDefaultWorkspaceCleanupBrowseState()
|
||||
)
|
||||
const normalized = normalizeWorkspaceCleanupBrowseState({
|
||||
activePresetId: 42,
|
||||
sort: { field: 'from-the-future', direction: 'sideways' },
|
||||
filters: {
|
||||
query: 7,
|
||||
activity: { idleSignal: 'nope', idleMinDays: -5 },
|
||||
safety: { blockers: ['live-agent', 'live-agent', 'not-a-blocker'], dismissed: 'maybe' },
|
||||
unknownGroup: { anything: true }
|
||||
},
|
||||
customPresets: 'nope'
|
||||
})
|
||||
expect(normalized.activePresetId).toBeNull()
|
||||
expect(normalized.sort).toEqual({ field: 'last-activity', direction: 'asc' })
|
||||
expect(normalized.filters.query).toBe('')
|
||||
expect(normalized.filters.activity.idleSignal).toBe('last-visited')
|
||||
expect(normalized.filters.activity.idleMinDays).toBe(0)
|
||||
expect(normalized.filters.safety.blockers).toEqual(['live-agent'])
|
||||
expect(normalized.filters.safety.dismissed).toBe('exclude')
|
||||
expect(normalized.customPresets).toEqual([])
|
||||
expect(normalized).not.toHaveProperty('unknownGroup')
|
||||
})
|
||||
|
||||
it('round-trips a saved state through JSON', () => {
|
||||
const state = createDefaultWorkspaceCleanupBrowseState()
|
||||
state.filters.activity.idleMinDays = 45
|
||||
state.filters.status.workspaceStatuses = ['in-review']
|
||||
state.customPresets = [
|
||||
createWorkspaceCleanupCustomPreset({
|
||||
id: 'mine',
|
||||
label: 'Mine',
|
||||
state: { filters: state.filters, sort: state.sort },
|
||||
createdAt: FACET_NOW
|
||||
})
|
||||
]
|
||||
expect(normalizeWorkspaceCleanupBrowseState(JSON.parse(JSON.stringify(state)))).toEqual(state)
|
||||
})
|
||||
|
||||
it('drops duplicate custom preset ids and entries without an id', () => {
|
||||
const normalized = normalizeWorkspaceCleanupBrowseState({
|
||||
customPresets: [{ id: 'a', label: 'A' }, { id: 'a', label: 'B' }, { label: 'no id' }]
|
||||
})
|
||||
expect(normalized.customPresets.map((preset) => preset.label)).toEqual(['A'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
buildWorkspaceCleanupFacetList,
|
||||
type WorkspaceCleanupFacetSources,
|
||||
type WorkspaceCleanupFacets
|
||||
} from './workspace-cleanup-facets'
|
||||
import {
|
||||
matchesWorkspaceCleanupActivity,
|
||||
matchesWorkspaceCleanupAgent,
|
||||
matchesWorkspaceCleanupContext,
|
||||
matchesWorkspaceCleanupGit,
|
||||
matchesWorkspaceCleanupLocation,
|
||||
matchesWorkspaceCleanupReview,
|
||||
matchesWorkspaceCleanupSafety,
|
||||
matchesWorkspaceCleanupSize,
|
||||
matchesWorkspaceCleanupStatus,
|
||||
matchesWorkspaceCleanupTicket
|
||||
} from './workspace-cleanup-facet-matchers'
|
||||
import { sortWorkspaceCleanupFacets } from './workspace-cleanup-facet-sort'
|
||||
import type {
|
||||
WorkspaceCleanupFilterState,
|
||||
WorkspaceCleanupSortState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
|
||||
export type WorkspaceCleanupQuery = {
|
||||
filters: WorkspaceCleanupFilterState
|
||||
sort: WorkspaceCleanupSortState
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupQueryResult = {
|
||||
/** Sorted rows that passed every facet. */
|
||||
rows: WorkspaceCleanupFacets[]
|
||||
/** Rows the user could actually queue for deletion, for the select-all affordance. */
|
||||
selectableWorktreeIds: string[]
|
||||
totalCount: number
|
||||
matchedCount: number
|
||||
}
|
||||
|
||||
export function matchesWorkspaceCleanupFilters(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filters: WorkspaceCleanupFilterState,
|
||||
now: number = Date.now()
|
||||
): boolean {
|
||||
const query = filters.query.trim().toLowerCase()
|
||||
if (query && !facets.searchText.includes(query)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
matchesWorkspaceCleanupSafety(facets, filters.safety) &&
|
||||
matchesWorkspaceCleanupActivity(facets, filters.activity, now) &&
|
||||
matchesWorkspaceCleanupSize(facets, filters.size) &&
|
||||
matchesWorkspaceCleanupStatus(facets, filters.status) &&
|
||||
matchesWorkspaceCleanupAgent(facets, filters.agent) &&
|
||||
matchesWorkspaceCleanupGit(facets, filters.git) &&
|
||||
matchesWorkspaceCleanupReview(facets, filters.review) &&
|
||||
matchesWorkspaceCleanupTicket(facets, filters.ticket) &&
|
||||
matchesWorkspaceCleanupContext(facets, filters.context) &&
|
||||
matchesWorkspaceCleanupLocation(facets, filters.location)
|
||||
)
|
||||
}
|
||||
|
||||
export function filterWorkspaceCleanupFacets(
|
||||
facets: readonly WorkspaceCleanupFacets[],
|
||||
filters: WorkspaceCleanupFilterState,
|
||||
now: number = Date.now()
|
||||
): WorkspaceCleanupFacets[] {
|
||||
return facets.filter((entry) => matchesWorkspaceCleanupFilters(entry, filters, now))
|
||||
}
|
||||
|
||||
export function runWorkspaceCleanupQuery(
|
||||
facets: readonly WorkspaceCleanupFacets[],
|
||||
query: WorkspaceCleanupQuery,
|
||||
now: number = Date.now()
|
||||
): WorkspaceCleanupQueryResult {
|
||||
const matched = filterWorkspaceCleanupFacets(facets, query.filters, now)
|
||||
const rows = sortWorkspaceCleanupFacets(matched, query.sort)
|
||||
return {
|
||||
rows,
|
||||
selectableWorktreeIds: rows.filter((row) => row.isSelectable).map((row) => row.worktreeId),
|
||||
totalCount: facets.length,
|
||||
matchedCount: rows.length
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot convenience for callers holding raw scan candidates. */
|
||||
export function queryWorkspaceCleanupCandidates(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
query: WorkspaceCleanupQuery,
|
||||
sources: WorkspaceCleanupFacetSources = {},
|
||||
now: number = Date.now()
|
||||
): WorkspaceCleanupQueryResult {
|
||||
return runWorkspaceCleanupQuery(buildWorkspaceCleanupFacetList(candidates, sources), query, now)
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-facet-group match counts for badge/count rendering. Each entry counts the
|
||||
* rows that would survive if only that group were applied, so a UI can show how
|
||||
* much each facet is costing without re-running the whole pipeline per group.
|
||||
*/
|
||||
export function countWorkspaceCleanupFacetMatches(
|
||||
facets: readonly WorkspaceCleanupFacets[],
|
||||
filters: WorkspaceCleanupFilterState,
|
||||
now: number = Date.now()
|
||||
): Record<keyof Omit<WorkspaceCleanupFilterState, 'query'>, number> {
|
||||
return {
|
||||
activity: count(facets, (row) => matchesWorkspaceCleanupActivity(row, filters.activity, now)),
|
||||
size: count(facets, (row) => matchesWorkspaceCleanupSize(row, filters.size)),
|
||||
status: count(facets, (row) => matchesWorkspaceCleanupStatus(row, filters.status)),
|
||||
agent: count(facets, (row) => matchesWorkspaceCleanupAgent(row, filters.agent)),
|
||||
git: count(facets, (row) => matchesWorkspaceCleanupGit(row, filters.git)),
|
||||
review: count(facets, (row) => matchesWorkspaceCleanupReview(row, filters.review)),
|
||||
ticket: count(facets, (row) => matchesWorkspaceCleanupTicket(row, filters.ticket)),
|
||||
context: count(facets, (row) => matchesWorkspaceCleanupContext(row, filters.context)),
|
||||
location: count(facets, (row) => matchesWorkspaceCleanupLocation(row, filters.location)),
|
||||
safety: count(facets, (row) => matchesWorkspaceCleanupSafety(row, filters.safety))
|
||||
}
|
||||
}
|
||||
|
||||
function count(
|
||||
facets: readonly WorkspaceCleanupFacets[],
|
||||
predicate: (facets: WorkspaceCleanupFacets) => boolean
|
||||
): number {
|
||||
let total = 0
|
||||
for (const entry of facets) {
|
||||
if (predicate(entry)) {
|
||||
total += 1
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/** 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'
|
||||
}
|
||||
const deltaMs = now - timestamp
|
||||
if (deltaMs < 60_000) {
|
||||
return 'Just now'
|
||||
}
|
||||
const minutes = Math.floor(deltaMs / 60_000)
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 48) {
|
||||
return `${hours}h ago`
|
||||
}
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import { formatBytes } from '../status-bar/workspace-space-format'
|
||||
import { CandidateRow, type WorkspaceCleanupDeletionPhase } from './workspace-cleanup-candidate-row'
|
||||
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
|
||||
import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices'
|
||||
import type { WorkspaceCleanupFacets } from './workspace-cleanup-facets'
|
||||
import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time'
|
||||
|
||||
export type WorkspaceCleanupRowListState = {
|
||||
rows: readonly WorkspaceCleanupFacets[]
|
||||
/** Rows the scan produced before filtering; separates "empty fleet" from "no matches". */
|
||||
scannedCount: number
|
||||
hasScanned: boolean
|
||||
loading: boolean
|
||||
deletionPhaseByWorktreeId: Record<string, WorkspaceCleanupDeletionPhase>
|
||||
deletingWorktreeIds: ReadonlySet<string>
|
||||
expandedRowIds: ReadonlySet<string>
|
||||
selectedIds: ReadonlySet<string>
|
||||
gitPendingWorktreeIds: ReadonlySet<string>
|
||||
rowFailures: Record<string, string>
|
||||
removalInFlight: boolean
|
||||
scrollElement: HTMLDivElement | null
|
||||
onClearFilters: () => void
|
||||
onToggleExpanded: (worktreeId: string) => void
|
||||
onToggleSelected: (worktreeId: string) => void
|
||||
onView: (candidate: WorkspaceCleanupCandidate) => void
|
||||
onIgnore: (candidate: WorkspaceCleanupCandidate) => void
|
||||
onRemove: (candidate: WorkspaceCleanupCandidate) => void
|
||||
}
|
||||
|
||||
export function WorkspaceCleanupRowList(props: WorkspaceCleanupRowListState): React.JSX.Element {
|
||||
const { rows, loading, hasScanned, scannedCount } = props
|
||||
const settled = !loading && hasScanned
|
||||
return (
|
||||
<>
|
||||
{settled && scannedCount === 0 ? (
|
||||
<WorkspaceCleanupEmptyState
|
||||
title={translate(
|
||||
'components.workspace.cleanup.browse.noWorkspaces',
|
||||
'No workspaces found.'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{hasScanned && scannedCount > 0 && rows.length === 0 ? (
|
||||
<WorkspaceCleanupEmptyState
|
||||
title={translate(
|
||||
'components.workspace.cleanup.browse.noMatches',
|
||||
'No workspaces match these filters.'
|
||||
)}
|
||||
description={translate(
|
||||
'components.workspace.cleanup.browse.noMatchesDescription',
|
||||
'Every workspace is in one list — widen a facet or clear the filters.'
|
||||
)}
|
||||
actionLabel={translate(
|
||||
'components.workspace.cleanup.browse.clearFilters',
|
||||
'Clear filters'
|
||||
)}
|
||||
onAction={props.onClearFilters}
|
||||
/>
|
||||
) : null}
|
||||
<WorkspaceCleanupCandidateList
|
||||
rows={rows}
|
||||
scrollElement={props.scrollElement}
|
||||
renderRow={(row, index) => (
|
||||
<CandidateRow
|
||||
key={row.worktreeId}
|
||||
candidate={row.candidate}
|
||||
reviewInfo={row.review}
|
||||
last={rows.length > 1 && index === rows.length - 1}
|
||||
expanded={props.expandedRowIds.has(row.worktreeId)}
|
||||
lastActivityLabel={formatWorkspaceCleanupRelativeTime(row.lastActivityAt)}
|
||||
sizeLabel={row.sizeBytes === null ? null : formatBytes(row.sizeBytes)}
|
||||
gitEvidencePending={props.gitPendingWorktreeIds.has(row.worktreeId)}
|
||||
deletionPhase={props.deletionPhaseByWorktreeId[row.worktreeId]}
|
||||
// Why: a background rescan must not lock rows; only an actual
|
||||
// removal batch or this row's own deletion disables it.
|
||||
removing={props.removalInFlight || props.deletingWorktreeIds.has(row.worktreeId)}
|
||||
selected={
|
||||
props.selectedIds.has(row.worktreeId) &&
|
||||
!props.deletingWorktreeIds.has(row.worktreeId)
|
||||
}
|
||||
failure={props.rowFailures[row.worktreeId]}
|
||||
onToggleExpanded={props.onToggleExpanded}
|
||||
onToggleSelected={props.onToggleSelected}
|
||||
onView={props.onView}
|
||||
onIgnore={props.onIgnore}
|
||||
onRemove={props.onRemove}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type {
|
||||
WorkspaceCleanupScanError,
|
||||
WorkspaceCleanupScanProgress
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
|
||||
function isDisconnectedRemoteScanError(message: string): boolean {
|
||||
return (
|
||||
message === 'SSH provider is unavailable.' ||
|
||||
message === 'Remote workspaces are not connected. Reconnect and refresh to check them.'
|
||||
)
|
||||
}
|
||||
|
||||
export function formatWorkspaceCleanupScanNotice(
|
||||
errors: readonly WorkspaceCleanupScanError[],
|
||||
repoNameById: ReadonlyMap<string, string>
|
||||
): string | null {
|
||||
const visibleErrors = errors.filter(
|
||||
(error) => !isDisconnectedRemoteScanError(error.message ?? '')
|
||||
)
|
||||
if (visibleErrors.length === 0) {
|
||||
return null
|
||||
}
|
||||
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.`
|
||||
}
|
||||
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.`
|
||||
}
|
||||
|
||||
export function formatWorkspaceCleanupScanProgress(
|
||||
progress: WorkspaceCleanupScanProgress | null
|
||||
): string {
|
||||
if (!progress || progress.scannedWorktreeCount === 0) {
|
||||
return translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe',
|
||||
'Finding inactive workspaces...'
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181',
|
||||
'Checked workspaces so far: {{value0}}',
|
||||
{ value0: progress.scannedWorktreeCount }
|
||||
)
|
||||
}
|
||||
|
||||
export function formatWorkspaceCleanupReadyToast(
|
||||
inactiveCount: number,
|
||||
suggestedCount: number
|
||||
): string {
|
||||
if (inactiveCount === 0) {
|
||||
return 'No workspaces found.'
|
||||
}
|
||||
const workspaceNoun = inactiveCount === 1 ? 'workspace' : 'workspaces'
|
||||
const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions'
|
||||
return `${inactiveCount} ${workspaceNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.`
|
||||
}
|
||||
|
||||
function formatScanErrorRepoName(
|
||||
error: Partial<WorkspaceCleanupScanError>,
|
||||
repoNameById: ReadonlyMap<string, string>
|
||||
): string {
|
||||
const repoName = error.repoName?.trim()
|
||||
if (repoName) {
|
||||
return repoName
|
||||
}
|
||||
const fallback = error.repoId ? repoNameById.get(error.repoId)?.trim() : ''
|
||||
return fallback || '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 message.replace(/\.$/, '')
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// @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 { WorkspaceCleanupSortState } from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header'
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function render(sort: WorkspaceCleanupSortState, handlers: Record<string, unknown> = {}): void {
|
||||
act(() =>
|
||||
root?.render(
|
||||
<WorkspaceCleanupSortHeader
|
||||
sort={sort}
|
||||
selectableCount={3}
|
||||
selectedCount={0}
|
||||
onToggleSortField={vi.fn()}
|
||||
onSetSort={vi.fn()}
|
||||
onToggleSelectAll={vi.fn()}
|
||||
{...handlers}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function header(field: string): HTMLButtonElement | null {
|
||||
return container?.querySelector<HTMLButtonElement>(`[data-sort-field="${field}"]`) ?? null
|
||||
}
|
||||
|
||||
describe('WorkspaceCleanupSortHeader', () => {
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount())
|
||||
}
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
it('marks the active sort column', () => {
|
||||
render({ field: 'size', direction: 'desc' })
|
||||
|
||||
expect(header('size')?.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(header('name')?.getAttribute('aria-pressed')).toBe('false')
|
||||
})
|
||||
|
||||
it('routes a column click through the sort toggle', () => {
|
||||
const onToggleSortField = vi.fn()
|
||||
render({ field: 'last-activity', direction: 'asc' }, { onToggleSortField })
|
||||
|
||||
act(() => header('repo')?.click())
|
||||
|
||||
expect(onToggleSortField).toHaveBeenCalledWith('repo')
|
||||
})
|
||||
|
||||
it('select-all reads the query result rather than the rendered page', () => {
|
||||
const onToggleSelectAll = vi.fn()
|
||||
render({ field: 'name', direction: 'asc' }, { onToggleSelectAll })
|
||||
|
||||
const checkbox = container?.querySelector<HTMLButtonElement>('[role="checkbox"]')
|
||||
expect(checkbox?.getAttribute('aria-checked')).toBe('false')
|
||||
act(() => checkbox?.click())
|
||||
|
||||
expect(onToggleSelectAll).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import React from 'react'
|
||||
import { ArrowDown, ArrowUp, Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { WORKSPACE_CLEANUP_SORT_FIELD_VALUES } from '../../../../shared/workspace-cleanup-facet-rankings'
|
||||
import type {
|
||||
WorkspaceCleanupSortField,
|
||||
WorkspaceCleanupSortState
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import { getWorkspaceCleanupSortFieldLabel } from './workspace-cleanup-facet-labels'
|
||||
|
||||
/** Columns the flat list actually renders; the rest stay in the overflow menu. */
|
||||
const PRIMARY_SORT_FIELDS: readonly WorkspaceCleanupSortField[] = [
|
||||
'name',
|
||||
'repo',
|
||||
'git',
|
||||
'review',
|
||||
'size',
|
||||
'last-activity'
|
||||
]
|
||||
|
||||
export function WorkspaceCleanupSortHeader({
|
||||
sort,
|
||||
selectableCount,
|
||||
selectedCount,
|
||||
onToggleSortField,
|
||||
onSetSort,
|
||||
onToggleSelectAll
|
||||
}: {
|
||||
sort: WorkspaceCleanupSortState
|
||||
selectableCount: number
|
||||
selectedCount: number
|
||||
onToggleSortField: (field: WorkspaceCleanupSortField) => void
|
||||
onSetSort: (next: WorkspaceCleanupSortState) => void
|
||||
onToggleSelectAll: (selectAll: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const allSelected = selectableCount > 0 && selectedCount >= selectableCount
|
||||
return (
|
||||
<div className="flex items-center gap-1 border-b border-border bg-muted/25 px-3 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={allSelected}
|
||||
disabled={selectableCount === 0}
|
||||
aria-label={translate(
|
||||
'components.workspace.cleanup.browse.selectAll',
|
||||
'Select all matching workspaces'
|
||||
)}
|
||||
onClick={() => onToggleSelectAll(!allSelected)}
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background text-primary hover:bg-accent disabled:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
{allSelected ? <Check className="size-3" strokeWidth={3} /> : null}
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-0.5">
|
||||
{PRIMARY_SORT_FIELDS.map((field) => (
|
||||
<SortHeaderButton
|
||||
key={field}
|
||||
field={field}
|
||||
sort={sort}
|
||||
onToggleSortField={onToggleSortField}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="shrink-0 text-[11px] text-muted-foreground"
|
||||
aria-label={translate('components.workspace.cleanup.browse.moreSorts', 'More sorts')}
|
||||
>
|
||||
<ChevronsUpDown className="size-3" />
|
||||
{translate('components.workspace.cleanup.browse.moreSorts', 'More sorts')}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
{translate('components.workspace.cleanup.browse.sortBy', 'Sort by')}
|
||||
</DropdownMenuLabel>
|
||||
{WORKSPACE_CLEANUP_SORT_FIELD_VALUES.map((field) => (
|
||||
<DropdownMenuItem
|
||||
key={field}
|
||||
onSelect={() =>
|
||||
onSetSort({
|
||||
field,
|
||||
direction:
|
||||
sort.field === field && sort.direction === 'asc' ? 'desc' : sort.direction
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="flex-1">{getWorkspaceCleanupSortFieldLabel(field)}</span>
|
||||
{sort.field === field ? <SortDirectionIcon direction={sort.direction} /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortHeaderButton({
|
||||
field,
|
||||
sort,
|
||||
onToggleSortField
|
||||
}: {
|
||||
field: WorkspaceCleanupSortField
|
||||
sort: WorkspaceCleanupSortState
|
||||
onToggleSortField: (field: WorkspaceCleanupSortField) => void
|
||||
}): React.JSX.Element {
|
||||
const active = sort.field === field
|
||||
const label = getWorkspaceCleanupSortFieldLabel(field)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-sort-field={field}
|
||||
aria-pressed={active}
|
||||
aria-label={translate(
|
||||
'components.workspace.cleanup.browse.sortByField',
|
||||
'Sort by {{value0}}',
|
||||
{
|
||||
value0: label
|
||||
}
|
||||
)}
|
||||
onClick={() => onToggleSortField(field)}
|
||||
className={cn(
|
||||
'inline-flex h-6 items-center gap-1 rounded-md px-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
active && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{active ? <SortDirectionIcon direction={sort.direction} /> : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SortDirectionIcon({ direction }: { direction: 'asc' | 'desc' }): React.JSX.Element {
|
||||
return direction === 'asc' ? (
|
||||
<ArrowUp className="size-3" aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDown className="size-3" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveWorkspaceCleanupActiveView,
|
||||
type WorkspaceCleanupViewCounts
|
||||
} from './workspace-cleanup-view-selection'
|
||||
|
||||
const emptyCounts: WorkspaceCleanupViewCounts = {
|
||||
ready: 0,
|
||||
review: 0,
|
||||
protected: 0,
|
||||
hidden: 0
|
||||
}
|
||||
|
||||
describe('resolveWorkspaceCleanupActiveView', () => {
|
||||
it('keeps the requested view while it still has rows', () => {
|
||||
expect(
|
||||
resolveWorkspaceCleanupActiveView({
|
||||
requestedView: 'review',
|
||||
counts: { ...emptyCounts, ready: 2, review: 1 },
|
||||
open: true,
|
||||
loading: false,
|
||||
hasScan: true
|
||||
})
|
||||
).toBe('review')
|
||||
})
|
||||
|
||||
it('falls back to the first populated cleanup view when the requested view is empty', () => {
|
||||
expect(
|
||||
resolveWorkspaceCleanupActiveView({
|
||||
requestedView: 'protected',
|
||||
counts: { ...emptyCounts, review: 3, hidden: 1 },
|
||||
open: true,
|
||||
loading: false,
|
||||
hasScan: true
|
||||
})
|
||||
).toBe('review')
|
||||
})
|
||||
|
||||
it('uses hidden suggestions when no visible cleanup views have rows', () => {
|
||||
expect(
|
||||
resolveWorkspaceCleanupActiveView({
|
||||
requestedView: 'ready',
|
||||
counts: { ...emptyCounts, hidden: 2 },
|
||||
open: true,
|
||||
loading: false,
|
||||
hasScan: true
|
||||
})
|
||||
).toBe('hidden')
|
||||
})
|
||||
|
||||
it('leaves the requested view alone before an open completed scan', () => {
|
||||
expect(
|
||||
resolveWorkspaceCleanupActiveView({
|
||||
requestedView: 'protected',
|
||||
counts: { ...emptyCounts, ready: 4 },
|
||||
open: false,
|
||||
loading: false,
|
||||
hasScan: true
|
||||
})
|
||||
).toBe('protected')
|
||||
|
||||
expect(
|
||||
resolveWorkspaceCleanupActiveView({
|
||||
requestedView: 'protected',
|
||||
counts: { ...emptyCounts, ready: 4 },
|
||||
open: true,
|
||||
loading: true,
|
||||
hasScan: true
|
||||
})
|
||||
).toBe('protected')
|
||||
|
||||
expect(
|
||||
resolveWorkspaceCleanupActiveView({
|
||||
requestedView: 'protected',
|
||||
counts: { ...emptyCounts, ready: 4 },
|
||||
open: true,
|
||||
loading: false,
|
||||
hasScan: false
|
||||
})
|
||||
).toBe('protected')
|
||||
})
|
||||
|
||||
it('keeps an empty requested view when every view is empty', () => {
|
||||
expect(
|
||||
resolveWorkspaceCleanupActiveView({
|
||||
requestedView: 'hidden',
|
||||
counts: emptyCounts,
|
||||
open: true,
|
||||
loading: false,
|
||||
hasScan: true
|
||||
})
|
||||
).toBe('hidden')
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
export type WorkspaceCleanupView = 'ready' | 'review' | 'protected' | 'hidden'
|
||||
|
||||
export type WorkspaceCleanupViewCounts = Record<WorkspaceCleanupView, number>
|
||||
|
||||
export function resolveWorkspaceCleanupActiveView({
|
||||
requestedView,
|
||||
counts,
|
||||
open,
|
||||
loading,
|
||||
hasScan
|
||||
}: {
|
||||
requestedView: WorkspaceCleanupView
|
||||
counts: WorkspaceCleanupViewCounts
|
||||
open: boolean
|
||||
loading: boolean
|
||||
hasScan: boolean
|
||||
}): WorkspaceCleanupView {
|
||||
if (!open || loading || !hasScan || counts[requestedView] > 0) {
|
||||
return requestedView
|
||||
}
|
||||
if (counts.ready > 0) {
|
||||
return 'ready'
|
||||
}
|
||||
if (counts.review > 0) {
|
||||
return 'review'
|
||||
}
|
||||
if (counts.protected > 0) {
|
||||
return 'protected'
|
||||
}
|
||||
if (counts.hidden > 0) {
|
||||
return 'hidden'
|
||||
}
|
||||
return requestedView
|
||||
}
|
||||
@@ -15118,7 +15118,12 @@
|
||||
"none": "Has none"
|
||||
},
|
||||
"tierField": "Cleanup tier",
|
||||
"idleSignalField": "Idle signal"
|
||||
"idleSignalField": "Idle signal",
|
||||
"selectionVanishedOne": "1 selected workspace no longer exists.",
|
||||
"selectionVanished": "{{value0}} selected workspaces no longer exist.",
|
||||
"updatedAgo": "Updated {{value0}}",
|
||||
"refreshing": "Refreshing…",
|
||||
"refreshingProgress": "Refreshing {{value0}}/{{value1}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type {
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
NOW,
|
||||
createCleanupTestStore,
|
||||
deferred,
|
||||
installWorkspaceCleanupApi,
|
||||
makeCandidate
|
||||
} from './workspace-cleanup-slice-test-harness'
|
||||
|
||||
const CACHED_AT = NOW - 60 * 60 * 1000
|
||||
|
||||
function makeCachedScan(): WorkspaceCleanupScanResult {
|
||||
return {
|
||||
scannedAt: CACHED_AT,
|
||||
candidates: [
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/alpha', displayName: 'alpha' }),
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/beta', displayName: 'beta' })
|
||||
],
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace cleanup cache hydration', () => {
|
||||
it('hydrates an empty slice from the persisted snapshot', async () => {
|
||||
const scan = vi.fn()
|
||||
const getCachedScan = vi.fn().mockResolvedValue(makeCachedScan())
|
||||
installWorkspaceCleanupApi(scan, getCachedScan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
await expect(store.getState().hydrateWorkspaceCleanupFromCache()).resolves.toBe(true)
|
||||
|
||||
expect(scan).not.toHaveBeenCalled()
|
||||
expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(CACHED_AT)
|
||||
expect(
|
||||
store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId)
|
||||
).toEqual(['repo1::/tmp/alpha', 'repo1::/tmp/beta'])
|
||||
})
|
||||
|
||||
it('never clobbers live scan data with the snapshot', async () => {
|
||||
const getCachedScan = vi.fn().mockResolvedValue(makeCachedScan())
|
||||
installWorkspaceCleanupApi(vi.fn(), getCachedScan)
|
||||
const store = createCleanupTestStore()
|
||||
const live = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
|
||||
store.setState({ workspaceCleanupScan: live } as Partial<AppState>)
|
||||
|
||||
await expect(store.getState().hydrateWorkspaceCleanupFromCache()).resolves.toBe(false)
|
||||
|
||||
expect(getCachedScan).not.toHaveBeenCalled()
|
||||
expect(store.getState().workspaceCleanupScan).toBe(live)
|
||||
})
|
||||
|
||||
it('bails when a broad scan starts while the cache read is in flight', async () => {
|
||||
const cacheRead = deferred<WorkspaceCleanupScanResult>()
|
||||
const broadScan = deferred<WorkspaceCleanupScanResult>()
|
||||
const scan = vi.fn().mockReturnValue(broadScan.promise)
|
||||
installWorkspaceCleanupApi(scan, vi.fn().mockReturnValue(cacheRead.promise))
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const hydration = store.getState().hydrateWorkspaceCleanupFromCache()
|
||||
const scanPromise = store.getState().scanWorkspaceCleanup()
|
||||
cacheRead.resolve(makeCachedScan())
|
||||
|
||||
await expect(hydration).resolves.toBe(false)
|
||||
expect(store.getState().workspaceCleanupScan).toBeNull()
|
||||
|
||||
broadScan.resolve({ scannedAt: NOW, candidates: [], errors: [] })
|
||||
await scanPromise
|
||||
expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW)
|
||||
})
|
||||
|
||||
it('reconciles a refresh into the hydrated snapshot instead of clearing it', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
const scan = vi.fn((_args, progressCallback) => {
|
||||
onProgress = progressCallback
|
||||
return pending.promise
|
||||
})
|
||||
installWorkspaceCleanupApi(scan, vi.fn().mockResolvedValue(makeCachedScan()))
|
||||
const store = createCleanupTestStore()
|
||||
await store.getState().hydrateWorkspaceCleanupFromCache()
|
||||
|
||||
const scanPromise = store.getState().scanWorkspaceCleanup()
|
||||
const refreshedAlpha = makeCandidate({
|
||||
worktreeId: 'repo1::/tmp/alpha',
|
||||
displayName: 'alpha',
|
||||
lastActivityAt: NOW - 1000,
|
||||
fingerprint: 'fingerprint-alpha-2'
|
||||
})
|
||||
const gamma = makeCandidate({ worktreeId: 'repo1::/tmp/gamma', displayName: 'gamma' })
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 3,
|
||||
candidates: [refreshedAlpha, gamma],
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
|
||||
})
|
||||
const streaming = store.getState().workspaceCleanupScan
|
||||
// Update in place, keep unseen snapshot rows, append new — never clear.
|
||||
expect(streaming?.candidates.map((candidate) => candidate.worktreeId)).toEqual([
|
||||
'repo1::/tmp/alpha',
|
||||
'repo1::/tmp/beta',
|
||||
'repo1::/tmp/gamma'
|
||||
])
|
||||
expect(streaming?.candidates[0]?.fingerprint).toBe('fingerprint-alpha-2')
|
||||
// The presented "as of" time stays the snapshot's until the scan settles.
|
||||
expect(streaming?.scannedAt).toBe(CACHED_AT)
|
||||
|
||||
pending.resolve({ scannedAt: NOW, candidates: [refreshedAlpha, gamma], errors: [] })
|
||||
await scanPromise
|
||||
|
||||
const settled = store.getState().workspaceCleanupScan
|
||||
expect(settled?.scannedAt).toBe(NOW)
|
||||
// beta vanished from the final result, so only the settle removes it.
|
||||
expect(settled?.candidates.map((candidate) => candidate.worktreeId)).toEqual([
|
||||
'repo1::/tmp/alpha',
|
||||
'repo1::/tmp/gamma'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps one scan streaming across a dialog close and reopen', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
const scan = vi.fn((_args, progressCallback) => {
|
||||
onProgress = progressCallback
|
||||
return pending.promise
|
||||
})
|
||||
installWorkspaceCleanupApi(scan, vi.fn().mockResolvedValue(null))
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const first = store.getState().scanWorkspaceCleanup()
|
||||
const early = [
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/one' }),
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/two' })
|
||||
]
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 5,
|
||||
candidates: early,
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
|
||||
})
|
||||
|
||||
// Dialog closes at 40% and reopens: reopening adopts the in-flight scan.
|
||||
// The dialog joins via another scan call, which must not restart anything.
|
||||
const rejoined = store.getState().scanWorkspaceCleanup()
|
||||
expect(scan).toHaveBeenCalledTimes(1)
|
||||
|
||||
const late = [
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/three' }),
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/four' }),
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/five' })
|
||||
]
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 5,
|
||||
totalWorktreeCount: 5,
|
||||
candidates: late,
|
||||
errors: [],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(5)
|
||||
})
|
||||
|
||||
pending.resolve({ scannedAt: NOW, candidates: [...early, ...late], errors: [] })
|
||||
await Promise.all([first, rejoined])
|
||||
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(false)
|
||||
expect(
|
||||
store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId)
|
||||
).toEqual([
|
||||
'repo1::/tmp/one',
|
||||
'repo1::/tmp/two',
|
||||
'repo1::/tmp/three',
|
||||
'repo1::/tmp/four',
|
||||
'repo1::/tmp/five'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
WorkspaceCleanupCandidate,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
|
||||
/**
|
||||
* Stale-while-revalidate seed: fills an empty cleanup slice from the snapshot
|
||||
* main persisted, so the dialog renders a full list immediately and the
|
||||
* background rescan reconciles into it instead of rebuilding from nothing.
|
||||
*/
|
||||
export async function hydrateWorkspaceCleanupScanFromCache({
|
||||
hasLiveScanState,
|
||||
enrich,
|
||||
apply
|
||||
}: {
|
||||
/** True once live scan data exists or a broad scan runs — cache never clobbers it. */
|
||||
hasLiveScanState: () => boolean
|
||||
enrich: (candidates: readonly WorkspaceCleanupCandidate[]) => Promise<WorkspaceCleanupCandidate[]>
|
||||
apply: (scan: WorkspaceCleanupScanResult) => void
|
||||
}): Promise<boolean> {
|
||||
if (hasLiveScanState()) {
|
||||
return false
|
||||
}
|
||||
let cached: WorkspaceCleanupScanResult | null
|
||||
try {
|
||||
cached = await window.api.workspaceCleanup.getCachedScan()
|
||||
} catch {
|
||||
// Why: hydration is best-effort; the rescan that follows is the recovery.
|
||||
return false
|
||||
}
|
||||
if (cached === null || hasLiveScanState()) {
|
||||
return false
|
||||
}
|
||||
const candidates = await enrich(cached.candidates)
|
||||
// Why: enrichment awaits terminal probes; a scan may have started meanwhile.
|
||||
if (hasLiveScanState()) {
|
||||
return false
|
||||
}
|
||||
apply({ ...cached, candidates })
|
||||
return true
|
||||
}
|
||||
@@ -237,6 +237,7 @@ describe('workspace cleanup removal and protection', () => {
|
||||
|
||||
expect(scan).toHaveBeenCalledWith(
|
||||
{
|
||||
includeAllWorkspaces: true,
|
||||
skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace'])
|
||||
},
|
||||
expect.any(Function)
|
||||
|
||||
@@ -80,11 +80,15 @@ export function createCleanupTestStore(removeWorktree: ReturnType<typeof vi.fn>
|
||||
)
|
||||
}
|
||||
|
||||
export function installWorkspaceCleanupApi(scan: ReturnType<typeof vi.fn>) {
|
||||
export function installWorkspaceCleanupApi(
|
||||
scan: ReturnType<typeof vi.fn>,
|
||||
getCachedScan: ReturnType<typeof vi.fn> = vi.fn().mockResolvedValue(null)
|
||||
) {
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: {
|
||||
workspaceCleanup: {
|
||||
scan,
|
||||
getCachedScan,
|
||||
dismiss: vi.fn().mockResolvedValue(undefined),
|
||||
clearDismissals: vi.fn().mockResolvedValue(undefined),
|
||||
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { mapWithConcurrency } from '../../../../shared/map-with-concurrency'
|
||||
import { hydrateWorkspaceCleanupScanFromCache } from './workspace-cleanup-cache-hydration'
|
||||
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -56,6 +57,8 @@ export type WorkspaceCleanupSlice = {
|
||||
workspaceCleanupDismissals: Record<string, WorkspaceCleanupDismissal>
|
||||
workspaceCleanupViewedCandidates: Record<string, WorkspaceCleanupViewedCandidate>
|
||||
scanWorkspaceCleanup: (args?: WorkspaceCleanupScanArgs) => Promise<WorkspaceCleanupScanResult>
|
||||
/** Stale-while-revalidate seed; true when the persisted snapshot filled an empty slice. */
|
||||
hydrateWorkspaceCleanupFromCache: () => Promise<boolean>
|
||||
markWorkspaceCleanupCandidateViewed: (candidate: WorkspaceCleanupCandidate) => void
|
||||
dismissWorkspaceCleanupCandidates: (
|
||||
candidates: readonly WorkspaceCleanupCandidate[]
|
||||
@@ -158,6 +161,9 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
}
|
||||
|
||||
const scanArgs = {
|
||||
// Why: the dialog renders one flat list of every workspace, so a broad
|
||||
// scan must not stop at the 30-day-idle suggestions.
|
||||
includeAllWorkspaces: true,
|
||||
...args,
|
||||
skipGitWorktreeIds: [
|
||||
...new Set([
|
||||
@@ -235,6 +241,15 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
}
|
||||
},
|
||||
|
||||
hydrateWorkspaceCleanupFromCache: () =>
|
||||
hydrateWorkspaceCleanupScanFromCache({
|
||||
// Why: loading covers an in-flight broad scan whose progress may not have
|
||||
// reached the store yet; the cache must never clobber either.
|
||||
hasLiveScanState: () => get().workspaceCleanupScan !== null || get().workspaceCleanupLoading,
|
||||
enrich: (candidates) => enrichWorkspaceCleanupCandidates(candidates, get()),
|
||||
apply: (scan) => set({ workspaceCleanupScan: scan })
|
||||
}),
|
||||
|
||||
markWorkspaceCleanupCandidateViewed: (candidate) => {
|
||||
set((state) => ({
|
||||
workspaceCleanupViewedCandidates: {
|
||||
@@ -367,6 +382,7 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
|
||||
function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string {
|
||||
return JSON.stringify({
|
||||
includeAllWorkspaces: args.includeAllWorkspaces === true,
|
||||
skipGitWorktreeIds: [...new Set(args.skipGitWorktreeIds ?? [])].sort()
|
||||
})
|
||||
}
|
||||
@@ -424,11 +440,13 @@ async function applyWorkspaceCleanupProgress(
|
||||
return
|
||||
}
|
||||
const state = getState()
|
||||
// Why: a cached snapshot (or the previous settled scan) may already fill the
|
||||
// list; streamed rows reconcile into it by worktreeId so a refresh never
|
||||
// clears and rebuilds what the user is reading.
|
||||
const previousCandidates =
|
||||
progress.candidateMode === 'append' &&
|
||||
state.workspaceCleanupProgress?.scanId === progress.scanId
|
||||
? state.workspaceCleanupProgress.candidates
|
||||
: []
|
||||
: (state.workspaceCleanupScan?.candidates ?? [])
|
||||
const enrichedProgressCandidates = await enrichWorkspaceCleanupCandidatesForScan(
|
||||
progress.candidates,
|
||||
state,
|
||||
@@ -462,7 +480,10 @@ async function applyWorkspaceCleanupProgress(
|
||||
}
|
||||
return {
|
||||
workspaceCleanupScan: {
|
||||
scannedAt: progress.scannedAt,
|
||||
// Why: mid-refresh the list still mixes in rows from the previous
|
||||
// snapshot; the honest "as of" time stays the snapshot's until the new
|
||||
// scan settles and removes vanished rows.
|
||||
scannedAt: state.workspaceCleanupScan?.scannedAt ?? progress.scannedAt,
|
||||
candidates,
|
||||
errors: progress.errors
|
||||
},
|
||||
@@ -497,11 +518,8 @@ function mergeWorkspaceCleanupProgressCandidates({
|
||||
progress: WorkspaceCleanupScanProgress
|
||||
scanToken: number
|
||||
}): WorkspaceCleanupCandidate[] {
|
||||
if (progress.candidateMode !== 'append') {
|
||||
workspaceCleanupProgressCandidateIndex = null
|
||||
return [...nextCandidates]
|
||||
}
|
||||
|
||||
// Why: snapshot-mode ticks also merge — replacing would drop the
|
||||
// stale-while-revalidate rows this scan has not re-reported yet.
|
||||
if (nextCandidates.length === 0) {
|
||||
return previousCandidates as WorkspaceCleanupCandidate[]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export type WorkspaceSpaceSlice = {
|
||||
workspaceSpaceScanning: boolean
|
||||
applyWorkspaceSpaceProgress: (progress: WorkspaceSpaceScanProgress) => void
|
||||
cancelWorkspaceSpaceScan: () => Promise<boolean>
|
||||
/** Stale-while-revalidate seed; true when the persisted analysis filled an empty slice. */
|
||||
hydrateWorkspaceSpaceFromCache: () => Promise<boolean>
|
||||
refreshWorkspaceSpace: () => Promise<WorkspaceSpaceAnalysis>
|
||||
removeWorkspaceSpaceWorktrees: (worktreeIds: readonly string[]) => void
|
||||
}
|
||||
@@ -105,6 +107,25 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
}
|
||||
return cancelled
|
||||
},
|
||||
hydrateWorkspaceSpaceFromCache: async () => {
|
||||
const hasLiveAnalysis = (): boolean =>
|
||||
get().workspaceSpaceAnalysis !== null || get().workspaceSpaceScanning || inFlightScan !== null
|
||||
if (hasLiveAnalysis()) {
|
||||
return false
|
||||
}
|
||||
let cached: Awaited<ReturnType<typeof window.api.workspaceSpace.getCachedAnalysis>>
|
||||
try {
|
||||
cached = await window.api.workspaceSpace.getCachedAnalysis()
|
||||
} catch {
|
||||
// Why: hydration is best-effort; a manual scan remains the recovery path.
|
||||
return false
|
||||
}
|
||||
if (cached === null || hasLiveAnalysis()) {
|
||||
return false
|
||||
}
|
||||
set({ workspaceSpaceAnalysis: cached })
|
||||
return true
|
||||
},
|
||||
refreshWorkspaceSpace: async () => {
|
||||
if (inFlightScan) {
|
||||
return inFlightScan
|
||||
|
||||
Reference in New Issue
Block a user