mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
perf(workspace-cleanup): stream scan progress and size results
This commit is contained in:
@@ -252,6 +252,46 @@ describe('workspace cleanup broad scan opt-in', () => {
|
||||
).toMatchObject({ clean: true, checkedAt: expect.any(Number) })
|
||||
})
|
||||
|
||||
it('batches fleet progress instead of emitting one IPC payload per row', async () => {
|
||||
const worktrees = [
|
||||
GIT_WORKTREES[0],
|
||||
...Array.from(
|
||||
{ length: 200 },
|
||||
(_, index): GitWorktreeInfo => ({
|
||||
path: `/repo-batch-${index}`,
|
||||
head: `head-${index}`,
|
||||
branch: `refs/heads/batch-${index}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
})
|
||||
)
|
||||
]
|
||||
listRepoWorktreesMock.mockResolvedValue(worktrees)
|
||||
const onProgress = vi.fn()
|
||||
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore(),
|
||||
{
|
||||
scanId: 'fleet-scan',
|
||||
includeAllWorkspaces: true,
|
||||
skipGitWorktreeIds: worktrees.map((worktree) => `${REPO.id}::${worktree.path}`)
|
||||
},
|
||||
{ onProgress }
|
||||
)
|
||||
|
||||
expect(onProgress.mock.calls.length).toBeLessThanOrEqual(2)
|
||||
const progress = onProgress.mock.lastCall?.[0]
|
||||
expect(progress).toMatchObject({
|
||||
scannedWorktreeCount: result.candidates.length,
|
||||
totalWorktreeCount: result.candidates.length,
|
||||
candidateMode: 'append'
|
||||
})
|
||||
const streamedCandidates = onProgress.mock.calls.flatMap(([update]) => update.candidates)
|
||||
expect(streamedCandidates.map((candidate) => candidate.worktreeId)).toEqual(
|
||||
result.candidates.map((candidate) => candidate.worktreeId)
|
||||
)
|
||||
})
|
||||
|
||||
it('still forces a git read for a focused scan of a recent workspace', async () => {
|
||||
const result = await scanWorkspaceCleanup(makeStore(), {
|
||||
worktreeId: 'repo-1::/repo-recent',
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
WorkspaceCleanupCandidate,
|
||||
WorkspaceCleanupScanError,
|
||||
WorkspaceCleanupScanProgress
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import { appendWorkspaceCleanupItems } from './workspace-cleanup-scan-primitives'
|
||||
|
||||
const WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS = 100
|
||||
|
||||
export type WorkspaceCleanupScanOptions = {
|
||||
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupProgressEmitter = {
|
||||
addDiscovered: (count: number) => void
|
||||
addCandidate: (candidate: WorkspaceCleanupCandidate) => void
|
||||
addErrors: (errors: WorkspaceCleanupScanError[]) => void
|
||||
flush: () => void
|
||||
}
|
||||
|
||||
/** Caps renderer projection churn while preserving an immediate final progress state. */
|
||||
export function createWorkspaceCleanupProgressEmitter(
|
||||
scanId: string | undefined,
|
||||
scannedAt: number,
|
||||
options: WorkspaceCleanupScanOptions
|
||||
): WorkspaceCleanupProgressEmitter {
|
||||
const errors: WorkspaceCleanupScanError[] = []
|
||||
let pendingCandidates: WorkspaceCleanupCandidate[] = []
|
||||
let totalWorktreeCount = 0
|
||||
let scannedWorktreeCount = 0
|
||||
let dirty = false
|
||||
let emittedInitialDiscovery = false
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
const flush = (): void => {
|
||||
if (!scanId || !dirty) {
|
||||
return
|
||||
}
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
dirty = false
|
||||
const candidates = pendingCandidates
|
||||
pendingCandidates = []
|
||||
options.onProgress?.({
|
||||
scanId,
|
||||
scannedAt,
|
||||
totalWorktreeCount,
|
||||
scannedWorktreeCount,
|
||||
candidates,
|
||||
errors: [...errors],
|
||||
candidateMode: 'append'
|
||||
})
|
||||
}
|
||||
const schedule = (): void => {
|
||||
if (!scanId) {
|
||||
return
|
||||
}
|
||||
dirty = true
|
||||
timer ??= setTimeout(flush, WORKSPACE_CLEANUP_PROGRESS_EMIT_INTERVAL_MS)
|
||||
}
|
||||
return {
|
||||
addDiscovered: (count) => {
|
||||
totalWorktreeCount += count
|
||||
schedule()
|
||||
if (!emittedInitialDiscovery) {
|
||||
emittedInitialDiscovery = true
|
||||
flush()
|
||||
}
|
||||
},
|
||||
addCandidate: (candidate) => {
|
||||
pendingCandidates.push(candidate)
|
||||
scannedWorktreeCount += 1
|
||||
schedule()
|
||||
},
|
||||
addErrors: (newErrors) => {
|
||||
appendWorkspaceCleanupItems(errors, newErrors)
|
||||
schedule()
|
||||
},
|
||||
flush
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
WorkspaceCleanupCandidate,
|
||||
WorkspaceCleanupScanError,
|
||||
WorkspaceCleanupScanArgs,
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../shared/workspace-cleanup'
|
||||
import { shouldScanBroadWorkspaceCleanupWorktree } from './workspace-cleanup-scan-eligibility'
|
||||
@@ -34,25 +33,19 @@ import {
|
||||
} from './workspace-cleanup-scan-primitives'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import { listWorkspaceCleanupFolderWorkspaces } from './workspace-cleanup-folder-workspaces'
|
||||
import {
|
||||
createWorkspaceCleanupProgressEmitter,
|
||||
type WorkspaceCleanupScanOptions
|
||||
} from './workspace-cleanup-progress-emitter'
|
||||
|
||||
const WORKTREE_SCAN_CONCURRENCY = 3
|
||||
|
||||
type WorkspaceCleanupScanOptions = {
|
||||
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
}
|
||||
|
||||
type WorkspaceCleanupScanRepoProgress = {
|
||||
onWorktreesDiscovered?: (count: number) => void
|
||||
onCandidateScanned?: (candidate: WorkspaceCleanupCandidate) => void
|
||||
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
|
||||
}
|
||||
|
||||
type WorkspaceCleanupProgressEmitter = {
|
||||
addDiscovered: (count: number) => void
|
||||
addCandidate: (candidate: WorkspaceCleanupCandidate) => void
|
||||
addErrors: (errors: WorkspaceCleanupScanError[]) => void
|
||||
}
|
||||
|
||||
export async function scanWorkspaceCleanup(
|
||||
store: Store,
|
||||
args: WorkspaceCleanupScanArgs = {},
|
||||
@@ -66,27 +59,30 @@ export async function scanWorkspaceCleanup(
|
||||
const repos = parsedTarget
|
||||
? store.getRepos().filter((repo) => repo.id === parsedTarget.repoId)
|
||||
: store.getRepos()
|
||||
const progress = createProgressEmitter(args.scanId, scannedAt, options)
|
||||
const progress = createWorkspaceCleanupProgressEmitter(args.scanId, scannedAt, options)
|
||||
const errors: WorkspaceCleanupScanResult['errors'] = []
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
|
||||
for (const repo of repos) {
|
||||
const result = await scanRepoWorkspaces({
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeId: args.worktreeId,
|
||||
includeAllWorkspaces: args.includeAllWorkspaces === true,
|
||||
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []),
|
||||
onWorktreesDiscovered: progress.addDiscovered,
|
||||
onCandidateScanned: progress.addCandidate,
|
||||
onErrors: progress.addErrors
|
||||
})
|
||||
appendWorkspaceCleanupItems(candidates, result.candidates)
|
||||
appendWorkspaceCleanupItems(errors, result.errors)
|
||||
try {
|
||||
for (const repo of repos) {
|
||||
const result = await scanRepoWorkspaces({
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeId: args.worktreeId,
|
||||
includeAllWorkspaces: args.includeAllWorkspaces === true,
|
||||
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []),
|
||||
onWorktreesDiscovered: progress.addDiscovered,
|
||||
onCandidateScanned: progress.addCandidate,
|
||||
onErrors: progress.addErrors
|
||||
})
|
||||
appendWorkspaceCleanupItems(candidates, result.candidates)
|
||||
appendWorkspaceCleanupItems(errors, result.errors)
|
||||
}
|
||||
return { scannedAt, candidates, errors }
|
||||
} finally {
|
||||
progress.flush()
|
||||
}
|
||||
|
||||
return { scannedAt, candidates, errors }
|
||||
}
|
||||
|
||||
async function scanRepoWorkspaces(
|
||||
@@ -280,44 +276,3 @@ function handleRepoWorktreeListError(args: {
|
||||
onErrors?.(errors)
|
||||
return { scannedAt, candidates: [], errors }
|
||||
}
|
||||
|
||||
function createProgressEmitter(
|
||||
scanId: string | undefined,
|
||||
scannedAt: number,
|
||||
options: WorkspaceCleanupScanOptions
|
||||
): WorkspaceCleanupProgressEmitter {
|
||||
const errors: WorkspaceCleanupScanError[] = []
|
||||
let totalWorktreeCount = 0
|
||||
let scannedWorktreeCount = 0
|
||||
const emit = (
|
||||
candidates: WorkspaceCleanupCandidate[],
|
||||
candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot'
|
||||
): void => {
|
||||
if (!scanId) {
|
||||
return
|
||||
}
|
||||
options.onProgress?.({
|
||||
scanId,
|
||||
scannedAt,
|
||||
totalWorktreeCount,
|
||||
scannedWorktreeCount,
|
||||
candidates,
|
||||
errors: [...errors],
|
||||
candidateMode
|
||||
})
|
||||
}
|
||||
return {
|
||||
addDiscovered: (count) => {
|
||||
totalWorktreeCount += count
|
||||
emit([], 'append')
|
||||
},
|
||||
addCandidate: (candidate) => {
|
||||
scannedWorktreeCount += 1
|
||||
emit([candidate], 'append')
|
||||
},
|
||||
addErrors: (newErrors) => {
|
||||
appendWorkspaceCleanupItems(errors, newErrors)
|
||||
emit([], 'append')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,11 +251,11 @@ describe('workspace cleanup scan', () => {
|
||||
(event as WorkspaceCleanupScanProgress).candidateMode === 'append' &&
|
||||
(event as WorkspaceCleanupScanProgress).candidates.length > 0
|
||||
)
|
||||
expect(candidateProgress).toHaveLength(2)
|
||||
expect(candidateProgress.every((event) => event.candidates.length === 1)).toBe(true)
|
||||
expect(candidateProgress.length).toBeLessThanOrEqual(2)
|
||||
const progressWorktreeIds = candidateProgress.flatMap((event) =>
|
||||
event.candidates.map((candidate) => candidate.worktreeId)
|
||||
)
|
||||
expect(progressWorktreeIds).toHaveLength(2)
|
||||
expect(progressWorktreeIds).toEqual(
|
||||
expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b'])
|
||||
)
|
||||
|
||||
@@ -153,6 +153,63 @@ describe('registerWorkspaceSpaceHandlers', () => {
|
||||
expect(event.sender.send).toHaveBeenCalledWith('workspaceSpace:progress', progress)
|
||||
})
|
||||
|
||||
it('batches completed measurements without dropping throttled rows', async () => {
|
||||
const store = createStore()
|
||||
let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined
|
||||
let resolveScan!: (analysis: WorkspaceSpaceAnalysis) => void
|
||||
analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => {
|
||||
onProgress = options.onProgress
|
||||
return new Promise<WorkspaceSpaceAnalysis>((resolve) => {
|
||||
resolveScan = resolve
|
||||
})
|
||||
})
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
|
||||
try {
|
||||
registerWorkspaceSpaceHandlers(store)
|
||||
const event = createEvent()
|
||||
const promise = handlers.get('workspaceSpace:analyze')!(event)
|
||||
const base = {
|
||||
scanId: 'scan-1',
|
||||
state: 'running' as const,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
totalRepoCount: 1,
|
||||
scannedRepoCount: 0,
|
||||
totalWorktreeCount: 2,
|
||||
currentRepoDisplayName: 'orca',
|
||||
currentWorktreeDisplayName: 'feature'
|
||||
}
|
||||
onProgress?.({ ...base, scannedWorktreeCount: 0 })
|
||||
onProgress?.({
|
||||
...base,
|
||||
scannedWorktreeCount: 1,
|
||||
completedMeasurements: [{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }]
|
||||
})
|
||||
onProgress?.({
|
||||
...base,
|
||||
scannedWorktreeCount: 2,
|
||||
completedMeasurements: [{ worktreeId: 'b', status: 'ok', sizeBytes: 20 }]
|
||||
})
|
||||
resolveScan(createAnalysis(1))
|
||||
await promise
|
||||
|
||||
expect(event.sender.send).toHaveBeenCalledTimes(2)
|
||||
expect(event.sender.send).toHaveBeenLastCalledWith(
|
||||
'workspaceSpace:progress',
|
||||
expect.objectContaining({
|
||||
scannedWorktreeCount: 2,
|
||||
completedMeasurements: [
|
||||
{ worktreeId: 'a', status: 'ok', sizeBytes: 10 },
|
||||
{ worktreeId: 'b', status: 'ok', sizeBytes: 20 }
|
||||
]
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
now.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels the in-flight scan', async () => {
|
||||
const store = createStore()
|
||||
let signal: AbortSignal | undefined
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Store } from '../persistence'
|
||||
import type {
|
||||
WorkspaceSpaceAnalysis,
|
||||
WorkspaceSpaceAnalyzeResult,
|
||||
WorkspaceSpaceScanProgress
|
||||
WorkspaceSpaceScanProgress,
|
||||
WorkspaceSpaceWorktreeMeasurement
|
||||
} from '../../shared/workspace-space-types'
|
||||
import {
|
||||
analyzeWorkspaceSpace,
|
||||
@@ -46,7 +47,11 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
currentWorktreeDisplayName: null
|
||||
}
|
||||
let lastProgressSentAt = 0
|
||||
let pendingMeasurements: WorkspaceSpaceWorktreeMeasurement[] = []
|
||||
const sendProgress = (progress: WorkspaceSpaceScanProgress): void => {
|
||||
if (progress.completedMeasurements?.length) {
|
||||
pendingMeasurements.push(...progress.completedMeasurements)
|
||||
}
|
||||
// Why: large fleets can report one progress event per worktree; keep
|
||||
// the UI responsive without repainting the full Space page for each row.
|
||||
const now = Date.now()
|
||||
@@ -64,7 +69,12 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
|
||||
}
|
||||
lastProgressSentAt = now
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('workspaceSpace:progress', progress)
|
||||
const completedMeasurements = pendingMeasurements
|
||||
pendingMeasurements = []
|
||||
event.sender.send('workspaceSpace:progress', {
|
||||
...progress,
|
||||
...(completedMeasurements.length > 0 ? { completedMeasurements } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
// Why: large worktree fleets require real disk traversal; duplicate
|
||||
|
||||
@@ -221,6 +221,17 @@ describe('analyzeWorkspaceSpace', () => {
|
||||
scannedRepoCount: 1,
|
||||
scannedWorktreeCount: 1
|
||||
})
|
||||
expect(progress).toContainEqual(
|
||||
expect.objectContaining({
|
||||
completedMeasurements: [
|
||||
expect.objectContaining({
|
||||
worktreeId: `repo-1::${repoPath}`,
|
||||
status: 'ok',
|
||||
sizeBytes: expect.any(Number)
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects when a scan is cancelled before it starts', async () => {
|
||||
|
||||
@@ -703,8 +703,13 @@ function reportProgress(
|
||||
updates: Partial<WorkspaceSpaceProgressState>,
|
||||
onProgress: WorkspaceSpaceAnalyzeOptions['onProgress']
|
||||
): void {
|
||||
const completedMeasurements = updates.completedMeasurements
|
||||
Object.assign(progress, updates, { updatedAt: Date.now() })
|
||||
onProgress?.({ ...progress })
|
||||
delete progress.completedMeasurements
|
||||
onProgress?.({
|
||||
...progress,
|
||||
...(completedMeasurements?.length ? { completedMeasurements } : {})
|
||||
})
|
||||
}
|
||||
|
||||
async function scanRepo(
|
||||
@@ -793,7 +798,12 @@ async function scanRepo(
|
||||
)
|
||||
reportProgress(
|
||||
progress,
|
||||
{ scannedWorktreeCount: progress.scannedWorktreeCount + 1 },
|
||||
{
|
||||
scannedWorktreeCount: progress.scannedWorktreeCount + 1,
|
||||
completedMeasurements: [
|
||||
{ worktreeId: row.worktreeId, status: row.status, sizeBytes: row.sizeBytes }
|
||||
]
|
||||
},
|
||||
options.onProgress
|
||||
)
|
||||
return row
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup'
|
||||
import WorkspaceCleanupDialog from './WorkspaceCleanupDialog'
|
||||
|
||||
const probes = vi.hoisted(() => ({ facets: vi.fn() }))
|
||||
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() }))
|
||||
|
||||
vi.mock('sonner', () => ({ toast }))
|
||||
vi.mock('./use-workspace-cleanup-facet-rows', () => ({
|
||||
useWorkspaceCleanupFacetRows: () => {
|
||||
probes.facets()
|
||||
return {
|
||||
rows: [],
|
||||
selectableWorktreeIds: [],
|
||||
matchedCount: 0,
|
||||
totalCount: 0,
|
||||
facetCounts: {
|
||||
activity: 0,
|
||||
size: 0,
|
||||
status: 0,
|
||||
agent: 0,
|
||||
git: 0,
|
||||
review: 0,
|
||||
ticket: 0,
|
||||
context: 0,
|
||||
location: 0,
|
||||
safety: 0
|
||||
},
|
||||
options: { workspaceStatuses: [], hostIds: [], repos: [], reviewProviders: [] },
|
||||
reviewInfoByWorktreeId: new Map(),
|
||||
sizeByWorktreeId: new Map(),
|
||||
measuredSizeCount: 0
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('@/components/ui/dialog', async () => {
|
||||
const Passthrough = ({ children }: { children: ReactNode }) => <>{children}</>
|
||||
return {
|
||||
Dialog: Passthrough,
|
||||
DialogContent: () => <div data-workspace-cleanup-content="true" />
|
||||
}
|
||||
})
|
||||
|
||||
const initialState = useAppStore.getInitialState()
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
function emptyScan(scannedAt: number): WorkspaceCleanupScanResult {
|
||||
return { scannedAt, candidates: [], errors: [] }
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function openDialog(): Promise<void> {
|
||||
await act(async () => useAppStore.getState().openModal('workspace-cleanup'))
|
||||
await flush()
|
||||
}
|
||||
|
||||
async function closeDialog(): Promise<void> {
|
||||
await act(async () => useAppStore.getState().closeModal())
|
||||
await flush()
|
||||
}
|
||||
|
||||
function seedStore(scanWorkspaceCleanup: AppState['scanWorkspaceCleanup']): void {
|
||||
useAppStore.setState(initialState, true)
|
||||
useAppStore.setState({
|
||||
activeModal: 'none',
|
||||
workspaceCleanupScan: emptyScan(1),
|
||||
workspaceCleanupProgress: null,
|
||||
workspaceCleanupLoading: false,
|
||||
workspaceCleanupError: null,
|
||||
scanWorkspaceCleanup
|
||||
})
|
||||
}
|
||||
|
||||
describe('WorkspaceCleanupDialog mount gating', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
probes.facets.mockClear()
|
||||
toast.error.mockClear()
|
||||
toast.success.mockClear()
|
||||
seedStore(vi.fn(async () => emptyScan(2)))
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount())
|
||||
document.body.replaceChildren()
|
||||
useAppStore.setState(initialState, true)
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('drops heavy projections after the close animation', async () => {
|
||||
await act(async () => root.render(<WorkspaceCleanupDialog />))
|
||||
await flush()
|
||||
expect(probes.facets).not.toHaveBeenCalled()
|
||||
|
||||
await openDialog()
|
||||
expect(probes.facets).toHaveBeenCalled()
|
||||
|
||||
await closeDialog()
|
||||
await act(async () => vi.advanceTimersByTimeAsync(300))
|
||||
const hiddenCalls = probes.facets.mock.calls.length
|
||||
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
await act(async () => {
|
||||
useAppStore.setState({
|
||||
workspaceCleanupScan: emptyScan(index + 10),
|
||||
hostedReviewCache: { [`review-${index}`]: { data: null, fetchedAt: index } },
|
||||
deleteStateByWorktreeId: {}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
expect(probes.facets).toHaveBeenCalledTimes(hiddenCalls)
|
||||
})
|
||||
|
||||
it('keeps scan completion ownership after heavy content unmounts', async () => {
|
||||
let resolveScan!: (result: WorkspaceCleanupScanResult) => void
|
||||
const scanPromise = new Promise<WorkspaceCleanupScanResult>((resolve) => {
|
||||
resolveScan = resolve
|
||||
})
|
||||
const scanWorkspaceCleanup = vi.fn(() => scanPromise)
|
||||
seedStore(scanWorkspaceCleanup)
|
||||
await act(async () => root.render(<WorkspaceCleanupDialog />))
|
||||
await openDialog()
|
||||
|
||||
await closeDialog()
|
||||
await act(async () => vi.advanceTimersByTimeAsync(300))
|
||||
expect(container.querySelector('[data-workspace-cleanup-content]')).toBeNull()
|
||||
|
||||
await act(async () => resolveScan(emptyScan(20)))
|
||||
await flush()
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'Workspace scan ready',
|
||||
expect.objectContaining({ action: expect.objectContaining({ label: 'Review' }) })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -27,25 +27,41 @@ import {
|
||||
needsWorkspaceCleanupGitEvidence
|
||||
} from './workspace-cleanup-git-evidence'
|
||||
import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state'
|
||||
import {
|
||||
useWorkspaceCleanupDialogLifecycle,
|
||||
type WorkspaceCleanupDialogLifecycle
|
||||
} from './use-workspace-cleanup-dialog-lifecycle'
|
||||
import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows'
|
||||
import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evidence'
|
||||
import { useWorkspaceCleanupRemoval } from './use-workspace-cleanup-removal'
|
||||
import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order'
|
||||
import { useWorkspaceCleanupScanLifecycle } from './use-workspace-cleanup-scan-lifecycle'
|
||||
|
||||
/** One filterable list of every workspace Orca knows about. */
|
||||
export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
export default function WorkspaceCleanupDialog(): React.JSX.Element | null {
|
||||
const lifecycle = useWorkspaceCleanupDialogLifecycle()
|
||||
if (!lifecycle.mountedContent) {
|
||||
return null
|
||||
}
|
||||
return <WorkspaceCleanupDialogContent {...lifecycle} />
|
||||
}
|
||||
|
||||
function WorkspaceCleanupDialogContent({
|
||||
open,
|
||||
loading,
|
||||
closeModal,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
removal,
|
||||
startWorkspaceCleanupScan
|
||||
}: WorkspaceCleanupDialogLifecycle): React.JSX.Element {
|
||||
const scan = useAppStore((s) => s.workspaceCleanupScan)
|
||||
const scanProgress = useAppStore((s) => s.workspaceCleanupProgress)
|
||||
const loading = useAppStore((s) => s.workspaceCleanupLoading)
|
||||
const error = useAppStore((s) => s.workspaceCleanupError)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const markCandidateViewed = useAppStore((s) => s.markWorkspaceCleanupCandidateViewed)
|
||||
const dismissCandidates = useAppStore((s) => s.dismissWorkspaceCleanupCandidates)
|
||||
const refreshWorkspaceSpace = useAppStore((s) => s.refreshWorkspaceSpace)
|
||||
const workspaceSpaceScanning = useAppStore((s) => s.workspaceSpaceScanning)
|
||||
const workspaceSpaceProgress = useAppStore((s) => s.workspaceSpaceScanProgress)
|
||||
const deletionPhaseByWorktreeId = useAppStore(
|
||||
useShallow((s) => {
|
||||
const phases: Record<string, WorkspaceCleanupDeletionPhase> = {}
|
||||
@@ -62,53 +78,26 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
[deletionPhaseByWorktreeId]
|
||||
)
|
||||
|
||||
const open = activeModal === 'workspace-cleanup'
|
||||
const browse = useWorkspaceCleanupBrowseState()
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
|
||||
const [expandedRowIds, setExpandedRowIds] = useState<Set<string>>(() => new Set())
|
||||
const [rowsScrollElement, setRowsScrollElement] = useState<HTMLDivElement | null>(null)
|
||||
const selectedDefaultsScanAtRef = useRef<number | null>(null)
|
||||
const wasOpenRef = useRef(open)
|
||||
// Why: a refresh completing mid-open must not clobber a selection the user
|
||||
// already made (or was given) for this open — defaults apply at most once.
|
||||
const defaultsAppliedForOpenRef = useRef(false)
|
||||
const selectionTouchedRef = useRef(false)
|
||||
const mountedRef = useMountedRef()
|
||||
|
||||
const deselectRemovedIds = useCallback((removedIds: readonly string[]) => {
|
||||
if (removedIds.length === 0) {
|
||||
return
|
||||
useEffect(() => {
|
||||
if (open && !wasOpenRef.current) {
|
||||
defaultsAppliedForOpenRef.current = false
|
||||
selectionTouchedRef.current = false
|
||||
}
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const id of removedIds) {
|
||||
next.delete(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const removal = useWorkspaceCleanupRemoval({ onDeselect: deselectRemovedIds, closeModal })
|
||||
// Why: the controller object is rebuilt every render; effects must depend on
|
||||
// its stable members or they re-run on every keystroke.
|
||||
const { removalInFlightRef, resetForReopen, resetRowFailures } = removal
|
||||
wasOpenRef.current = open
|
||||
}, [open])
|
||||
|
||||
const onFreshOpen = useCallback(() => {
|
||||
defaultsAppliedForOpenRef.current = false
|
||||
selectionTouchedRef.current = false
|
||||
// Why: filters and sort persist across reopens; only volatile row state
|
||||
// resets, and not while a batch is still settling.
|
||||
if (!removalInFlightRef.current) {
|
||||
resetForReopen()
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
}, [removalInFlightRef, resetForReopen])
|
||||
|
||||
const { startWorkspaceCleanupScan } = useWorkspaceCleanupScanLifecycle({
|
||||
open,
|
||||
loading,
|
||||
removalInFlightRef,
|
||||
resetRowFailures,
|
||||
onFreshOpen
|
||||
})
|
||||
const { removalInFlightRef } = removal
|
||||
|
||||
const candidates = useMemo(() => scan?.candidates ?? [], [scan?.candidates])
|
||||
const gitEvidenceNeeded = open && needsWorkspaceCleanupGitEvidence(browse.filters, browse.sort)
|
||||
@@ -179,7 +168,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
}
|
||||
defaultsAppliedForOpenRef.current = true
|
||||
setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds))
|
||||
}, [deletingWorktreeIds, loading, removalInFlightRef, scan])
|
||||
}, [deletingWorktreeIds, loading, removalInFlightRef, scan, setSelectedIds])
|
||||
|
||||
const pruneSelectionToVisibleRows = useEffectEvent(() => {
|
||||
setSelectedIds((current) => {
|
||||
@@ -230,7 +219,11 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
void dismissCandidates([candidate])
|
||||
.then(() => {
|
||||
if (mountedRef.current) {
|
||||
deselectRemovedIds([candidate.worktreeId])
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(candidate.worktreeId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -245,17 +238,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
}
|
||||
})
|
||||
},
|
||||
[deselectRemovedIds, dismissCandidates, mountedRef]
|
||||
[dismissCandidates, mountedRef, setSelectedIds]
|
||||
)
|
||||
|
||||
const toggleExpandedRow = useCallback((worktreeId: string) => {
|
||||
setExpandedRowIds((current) => toggleSetMember(current, worktreeId))
|
||||
}, [])
|
||||
|
||||
const toggleSelectedRow = useCallback((worktreeId: string) => {
|
||||
selectionTouchedRef.current = true
|
||||
setSelectedIds((current) => toggleSetMember(current, worktreeId))
|
||||
}, [])
|
||||
const toggleSelectedRow = useCallback(
|
||||
(worktreeId: string) => {
|
||||
selectionTouchedRef.current = true
|
||||
setSelectedIds((current) => toggleSetMember(current, worktreeId))
|
||||
},
|
||||
[setSelectedIds]
|
||||
)
|
||||
|
||||
const selectableWorktreeIds = useMemo(
|
||||
() =>
|
||||
@@ -269,7 +265,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
selectionTouchedRef.current = true
|
||||
setSelectedIds(selectAll ? new Set(selectableWorktreeIds) : new Set())
|
||||
},
|
||||
[selectableWorktreeIds]
|
||||
[selectableWorktreeIds, setSelectedIds]
|
||||
)
|
||||
|
||||
// Why: stable per-row handlers so React.memo keeps unchanged CandidateRow
|
||||
@@ -286,7 +282,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
setSelectedIds(new Set([candidate.worktreeId]))
|
||||
openConfirmRemove([candidate])
|
||||
},
|
||||
[openConfirmRemove, removalInFlightRef]
|
||||
[openConfirmRemove, removalInFlightRef, setSelectedIds]
|
||||
)
|
||||
|
||||
const handleViewCandidate = useCallback(
|
||||
@@ -343,6 +339,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
selectableCount={selectableWorktreeIds.length}
|
||||
selectedCount={selectedCount}
|
||||
spaceScanning={workspaceSpaceScanning}
|
||||
spaceProgress={workspaceSpaceProgress}
|
||||
gitPendingCount={gitEvidence.pendingWorktreeIds.size}
|
||||
gitCheckedTotal={gitEvidence.totalCount}
|
||||
onRunSpaceScan={runSpaceScan}
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
useWorkspaceCleanupRemoval,
|
||||
type WorkspaceCleanupRemovalController
|
||||
} from './use-workspace-cleanup-removal'
|
||||
import { useWorkspaceCleanupScanLifecycle } from './use-workspace-cleanup-scan-lifecycle'
|
||||
|
||||
const WORKSPACE_CLEANUP_CLOSE_LINGER_MS = 300
|
||||
|
||||
export type WorkspaceCleanupDialogLifecycle = {
|
||||
open: boolean
|
||||
mountedContent: boolean
|
||||
loading: boolean
|
||||
closeModal: () => void
|
||||
selectedIds: Set<string>
|
||||
setSelectedIds: Dispatch<SetStateAction<Set<string>>>
|
||||
removal: WorkspaceCleanupRemovalController
|
||||
startWorkspaceCleanupScan: (options?: { notifyWhenReady?: boolean }) => void
|
||||
}
|
||||
|
||||
/** Keeps scan/removal ownership mounted while the closed dialog drops its projection tree. */
|
||||
export function useWorkspaceCleanupDialogLifecycle(): WorkspaceCleanupDialogLifecycle {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
const loading = useAppStore((s) => s.workspaceCleanupLoading)
|
||||
const open = activeModal === 'workspace-cleanup'
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
|
||||
|
||||
const deselectRemovedIds = useCallback((removedIds: readonly string[]) => {
|
||||
if (removedIds.length === 0) {
|
||||
return
|
||||
}
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const id of removedIds) {
|
||||
next.delete(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
const removal = useWorkspaceCleanupRemoval({ onDeselect: deselectRemovedIds, closeModal })
|
||||
const { removalInFlightRef, resetForReopen, resetRowFailures } = removal
|
||||
|
||||
const onFreshOpen = useCallback(() => {
|
||||
if (!removalInFlightRef.current) {
|
||||
resetForReopen()
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
}, [removalInFlightRef, resetForReopen])
|
||||
|
||||
const { startWorkspaceCleanupScan } = useWorkspaceCleanupScanLifecycle({
|
||||
open,
|
||||
loading,
|
||||
removalInFlightRef,
|
||||
resetRowFailures,
|
||||
onFreshOpen
|
||||
})
|
||||
const [mountedContent, setMountedContent] = useState(open)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMountedContent(true)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(
|
||||
() => setMountedContent(false),
|
||||
WORKSPACE_CLEANUP_CLOSE_LINGER_MS
|
||||
)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [open])
|
||||
|
||||
return {
|
||||
open,
|
||||
mountedContent,
|
||||
loading,
|
||||
closeModal,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
removal,
|
||||
startWorkspaceCleanupScan
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
createDefaultWorkspaceCleanupFilterState,
|
||||
DEFAULT_WORKSPACE_CLEANUP_SORT
|
||||
} from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
|
||||
import type * as FacetModule from './workspace-cleanup-facets'
|
||||
import type * as QueryModule from './workspace-cleanup-query'
|
||||
import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture'
|
||||
|
||||
const holders = vi.hoisted(() => ({ state: null as AppState | null }))
|
||||
const counts = vi.hoisted(() => ({ facetCounts: 0, measured: 0, queries: 0 }))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: <T,>(selector: (state: AppState) => T): T => {
|
||||
if (!holders.state) {
|
||||
throw new Error('Missing test state')
|
||||
}
|
||||
return selector(holders.state)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./workspace-cleanup-facets', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof FacetModule>()
|
||||
return {
|
||||
...actual,
|
||||
countWorkspaceCleanupMeasuredRows: (
|
||||
...args: Parameters<typeof actual.countWorkspaceCleanupMeasuredRows>
|
||||
) => {
|
||||
counts.measured += 1
|
||||
return actual.countWorkspaceCleanupMeasuredRows(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./workspace-cleanup-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof QueryModule>()
|
||||
return {
|
||||
...actual,
|
||||
countWorkspaceCleanupFacetMatches: (
|
||||
...args: Parameters<typeof actual.countWorkspaceCleanupFacetMatches>
|
||||
) => {
|
||||
counts.facetCounts += 1
|
||||
return actual.countWorkspaceCleanupFacetMatches(...args)
|
||||
},
|
||||
runWorkspaceCleanupQuery: (...args: Parameters<typeof actual.runWorkspaceCleanupQuery>) => {
|
||||
counts.queries += 1
|
||||
return actual.runWorkspaceCleanupQuery(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { useWorkspaceCleanupFacetRows } from './use-workspace-cleanup-facet-rows'
|
||||
|
||||
function makeState(): AppState {
|
||||
return {
|
||||
worktreesByRepo: {},
|
||||
hostedReviewCache: {},
|
||||
repos: [],
|
||||
settings: {},
|
||||
workspaceStatuses: cloneDefaultWorkspaceStatuses(),
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
agentStatusByPaneKey: {},
|
||||
tabsByWorktree: {},
|
||||
workspaceCleanupDismissals: {},
|
||||
workspaceSpaceAnalysis: null,
|
||||
workspaceSpaceMeasurements: []
|
||||
} as unknown as AppState
|
||||
}
|
||||
|
||||
describe('useWorkspaceCleanupFacetRows hot paths', () => {
|
||||
beforeEach(() => {
|
||||
holders.state = makeState()
|
||||
counts.facetCounts = 0
|
||||
counts.measured = 0
|
||||
counts.queries = 0
|
||||
})
|
||||
|
||||
it('does only the query work when the user types', () => {
|
||||
const candidates = [makeFacetCandidate()]
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
const view = renderHook(
|
||||
({ currentFilters }) =>
|
||||
useWorkspaceCleanupFacetRows({
|
||||
candidates,
|
||||
filters: currentFilters,
|
||||
sort: DEFAULT_WORKSPACE_CLEANUP_SORT,
|
||||
now: 1_700_000_000_000
|
||||
}),
|
||||
{ initialProps: { currentFilters: filters } }
|
||||
)
|
||||
const initialCounts = { ...counts }
|
||||
const reviewIndex = view.result.current.reviewInfoByWorktreeId
|
||||
|
||||
view.rerender({ currentFilters: { ...filters, query: 'alpha' } })
|
||||
|
||||
expect(counts.queries).toBeGreaterThan(initialCounts.queries)
|
||||
expect(counts.facetCounts).toBe(initialCounts.facetCounts)
|
||||
expect(counts.measured).toBe(initialCounts.measured)
|
||||
expect(view.result.current.reviewInfoByWorktreeId).toBe(reviewIndex)
|
||||
})
|
||||
|
||||
it('keeps review joins stable during unrelated agent-status churn', () => {
|
||||
const candidates = [makeFacetCandidate()]
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
const view = renderHook(() =>
|
||||
useWorkspaceCleanupFacetRows({
|
||||
candidates,
|
||||
filters,
|
||||
sort: DEFAULT_WORKSPACE_CLEANUP_SORT,
|
||||
now: 1_700_000_000_000
|
||||
})
|
||||
)
|
||||
const reviewIndex = view.result.current.reviewInfoByWorktreeId
|
||||
|
||||
holders.state = { ...holders.state!, agentStatusByPaneKey: {} }
|
||||
view.rerender()
|
||||
|
||||
expect(view.result.current.reviewInfoByWorktreeId).toBe(reviewIndex)
|
||||
})
|
||||
|
||||
it('does not rebuild facets for count-only size progress', () => {
|
||||
const candidates = [makeFacetCandidate()]
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
const view = renderHook(() =>
|
||||
useWorkspaceCleanupFacetRows({
|
||||
candidates,
|
||||
filters,
|
||||
sort: DEFAULT_WORKSPACE_CLEANUP_SORT,
|
||||
now: 1_700_000_000_000
|
||||
})
|
||||
)
|
||||
const initialCounts = { ...counts }
|
||||
|
||||
holders.state = {
|
||||
...holders.state!,
|
||||
workspaceSpaceScanProgress: {
|
||||
scanId: 'scan-1',
|
||||
state: 'running',
|
||||
startedAt: 1,
|
||||
updatedAt: 2,
|
||||
totalRepoCount: 1,
|
||||
scannedRepoCount: 0,
|
||||
totalWorktreeCount: 100,
|
||||
scannedWorktreeCount: 20,
|
||||
currentRepoDisplayName: 'Repo',
|
||||
currentWorktreeDisplayName: 'alpha'
|
||||
}
|
||||
}
|
||||
view.rerender()
|
||||
|
||||
expect(counts).toEqual(initialCounts)
|
||||
})
|
||||
|
||||
it('projects streamed size measurements before the full scan completes', () => {
|
||||
const candidates = [makeFacetCandidate()]
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
const view = renderHook(() =>
|
||||
useWorkspaceCleanupFacetRows({
|
||||
candidates,
|
||||
filters,
|
||||
sort: DEFAULT_WORKSPACE_CLEANUP_SORT,
|
||||
now: 1_700_000_000_000
|
||||
})
|
||||
)
|
||||
expect(view.result.current.rows[0]?.sizeBytes).toBeNull()
|
||||
|
||||
holders.state = {
|
||||
...holders.state!,
|
||||
workspaceSpaceMeasurements: [
|
||||
{ worktreeId: candidates[0]!.worktreeId, status: 'ok', sizeBytes: 4_096 }
|
||||
]
|
||||
}
|
||||
view.rerender()
|
||||
|
||||
expect(view.result.current.rows[0]?.sizeBytes).toBe(4_096)
|
||||
expect(view.result.current.measuredSizeCount).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -70,22 +70,34 @@ export function useWorkspaceCleanupFacetRows({
|
||||
agentStatusByPaneKey: s.agentStatusByPaneKey,
|
||||
tabsByWorktree: s.tabsByWorktree,
|
||||
dismissals: s.workspaceCleanupDismissals,
|
||||
spaceWorktrees: s.workspaceSpaceAnalysis?.worktrees ?? null
|
||||
spaceWorktrees: s.workspaceSpaceAnalysis?.worktrees ?? null,
|
||||
spaceMeasurements: s.workspaceSpaceMeasurements
|
||||
}))
|
||||
)
|
||||
const { hostedReviewCache, repos, settings, worktreesByRepo } = sources
|
||||
|
||||
const reviewInfoByWorktreeId = useMemo(() => {
|
||||
const infos = new Map<string, WorkspaceCleanupReviewInfo>()
|
||||
const reviewSources = { hostedReviewCache, repos, settings, worktreesByRepo }
|
||||
for (const candidate of candidates) {
|
||||
infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, sources))
|
||||
infos.set(candidate.worktreeId, getWorkspaceCleanupReviewInfo(candidate, reviewSources))
|
||||
}
|
||||
return infos
|
||||
}, [candidates, sources])
|
||||
}, [candidates, hostedReviewCache, repos, settings, worktreesByRepo])
|
||||
|
||||
const sizeByWorktreeId = useMemo(
|
||||
const completedSizeByWorktreeId = useMemo(
|
||||
() => buildWorkspaceCleanupSizeIndex(sources.spaceWorktrees),
|
||||
[sources.spaceWorktrees]
|
||||
)
|
||||
const sizeByWorktreeId = useMemo(() => {
|
||||
if (sources.spaceMeasurements.length === 0) {
|
||||
return completedSizeByWorktreeId
|
||||
}
|
||||
return new Map([
|
||||
...completedSizeByWorktreeId,
|
||||
...buildWorkspaceCleanupSizeIndex(sources.spaceMeasurements)
|
||||
])
|
||||
}, [completedSizeByWorktreeId, sources.spaceMeasurements])
|
||||
|
||||
const facets = useMemo(
|
||||
() =>
|
||||
@@ -102,17 +114,56 @@ export function useWorkspaceCleanupFacetRows({
|
||||
reviewInfoByWorktreeId,
|
||||
dismissedWorktreeIds: new Set(Object.keys(sources.dismissals))
|
||||
}),
|
||||
[candidates, now, reviewInfoByWorktreeId, sizeByWorktreeId, sources]
|
||||
[
|
||||
candidates,
|
||||
now,
|
||||
reviewInfoByWorktreeId,
|
||||
sizeByWorktreeId,
|
||||
sources.agentStatusByPaneKey,
|
||||
sources.dismissals,
|
||||
sources.lastVisitedAtByWorktreeId,
|
||||
sources.tabsByWorktree,
|
||||
sources.workspaceStatuses,
|
||||
sources.worktreesByRepo
|
||||
]
|
||||
)
|
||||
|
||||
const result = useMemo(
|
||||
() => runWorkspaceCleanupQuery(facets, { filters, sort }, now),
|
||||
[facets, filters, now, sort]
|
||||
)
|
||||
const facetCounts = useMemo(
|
||||
() => countWorkspaceCleanupFacetMatches(facets, filters, now),
|
||||
[facets, filters, now]
|
||||
const facetFilters = useMemo<WorkspaceCleanupFilterState>(
|
||||
() => ({
|
||||
query: '',
|
||||
activity: filters.activity,
|
||||
size: filters.size,
|
||||
status: filters.status,
|
||||
agent: filters.agent,
|
||||
git: filters.git,
|
||||
review: filters.review,
|
||||
ticket: filters.ticket,
|
||||
context: filters.context,
|
||||
location: filters.location,
|
||||
safety: filters.safety
|
||||
}),
|
||||
[
|
||||
filters.activity,
|
||||
filters.agent,
|
||||
filters.context,
|
||||
filters.git,
|
||||
filters.location,
|
||||
filters.review,
|
||||
filters.safety,
|
||||
filters.size,
|
||||
filters.status,
|
||||
filters.ticket
|
||||
]
|
||||
)
|
||||
const facetCounts = useMemo(
|
||||
() => countWorkspaceCleanupFacetMatches(facets, facetFilters, now),
|
||||
[facets, facetFilters, now]
|
||||
)
|
||||
const measuredSizeCount = useMemo(() => countWorkspaceCleanupMeasuredRows(facets), [facets])
|
||||
|
||||
const options = useMemo<WorkspaceCleanupFacetOptions>(
|
||||
() => ({
|
||||
@@ -144,6 +195,6 @@ export function useWorkspaceCleanupFacetRows({
|
||||
options,
|
||||
reviewInfoByWorktreeId,
|
||||
sizeByWorktreeId,
|
||||
measuredSizeCount: countWorkspaceCleanupMeasuredRows(facets)
|
||||
measuredSizeCount
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ export function useWorkspaceCleanupScanLifecycle({
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63',
|
||||
'Inactive workspace scan ready'
|
||||
'Workspace scan ready'
|
||||
),
|
||||
{
|
||||
description: formatWorkspaceCleanupReadyToast(
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { WorkspaceCleanupEmptyState } from './workspace-cleanup-dialog-notices'
|
||||
import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
|
||||
import { WorkspaceCleanupSortHeader } from './workspace-cleanup-sort-header'
|
||||
import type { WorkspaceSpaceScanProgress } from '../../../../shared/workspace-space-types'
|
||||
|
||||
export function WorkspaceCleanupBrowseToolbar({
|
||||
browse,
|
||||
@@ -16,6 +17,7 @@ export function WorkspaceCleanupBrowseToolbar({
|
||||
selectableCount,
|
||||
selectedCount,
|
||||
spaceScanning,
|
||||
spaceProgress,
|
||||
gitPendingCount,
|
||||
gitCheckedTotal,
|
||||
onRunSpaceScan,
|
||||
@@ -26,6 +28,7 @@ export function WorkspaceCleanupBrowseToolbar({
|
||||
selectableCount: number
|
||||
selectedCount: number
|
||||
spaceScanning: boolean
|
||||
spaceProgress: WorkspaceSpaceScanProgress | null
|
||||
gitPendingCount: number
|
||||
gitCheckedTotal: number
|
||||
onRunSpaceScan: () => void
|
||||
@@ -48,6 +51,8 @@ export function WorkspaceCleanupBrowseToolbar({
|
||||
sizeScan={{
|
||||
measuredCount: facetRows.measuredSizeCount,
|
||||
scanning: spaceScanning,
|
||||
scannedCount: spaceProgress?.scannedWorktreeCount ?? 0,
|
||||
totalCount: spaceProgress?.totalWorktreeCount ?? 0,
|
||||
onRun: onRunSpaceScan
|
||||
}}
|
||||
gitEvidence={{ pendingCount: gitPendingCount, totalCount: gitCheckedTotal }}
|
||||
@@ -61,7 +66,7 @@ export function WorkspaceCleanupBrowseToolbar({
|
||||
onToggleSortField={browse.toggleSortField}
|
||||
onToggleSelectAll={onToggleSelectAll}
|
||||
/>
|
||||
{browse.sort.field === 'size' && facetRows.measuredSizeCount === 0 ? (
|
||||
{browse.sort.field === 'size' && facetRows.measuredSizeCount === 0 && !spaceScanning ? (
|
||||
<WorkspaceCleanupEmptyState
|
||||
title={translate(
|
||||
'components.workspace.cleanup.browse.noSizes',
|
||||
|
||||
@@ -24,6 +24,7 @@ const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8
|
||||
export function WorkspaceCleanupCandidateList<Row extends WorkspaceCleanupListRow>({
|
||||
rows,
|
||||
renderRow,
|
||||
estimatedRowHeight = WORKSPACE_CLEANUP_ROW_ESTIMATE_PX,
|
||||
// Why: a state-held element, not a ref — the ScrollArea viewport is not
|
||||
// attached when this component first mounts, so a ref would leave the
|
||||
// virtualizer unobserved until some unrelated re-render.
|
||||
@@ -31,6 +32,7 @@ export function WorkspaceCleanupCandidateList<Row extends WorkspaceCleanupListRo
|
||||
}: {
|
||||
rows: readonly Row[]
|
||||
renderRow: (row: Row, index: number) => React.ReactNode
|
||||
estimatedRowHeight?: number
|
||||
scrollElement: HTMLDivElement | null
|
||||
}): React.JSX.Element {
|
||||
const virtualize = rows.length >= WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS
|
||||
@@ -39,7 +41,7 @@ export function WorkspaceCleanupCandidateList<Row extends WorkspaceCleanupListRo
|
||||
count: rows.length,
|
||||
enabled: virtualize && scrollElement !== null,
|
||||
getScrollElement: () => scrollElement,
|
||||
estimateSize: () => WORKSPACE_CLEANUP_ROW_ESTIMATE_PX,
|
||||
estimateSize: () => estimatedRowHeight,
|
||||
overscan: WORKSPACE_CLEANUP_ROW_OVERSCAN,
|
||||
// Why: stable worktree keys let the virtualizer carry row identity across
|
||||
// scan refreshes instead of remounting the window on every streamed row.
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeFacetCandidate } from './workspace-cleanup-facet.test.fixture'
|
||||
import { WorkspaceCleanupConfirmRemove } from './workspace-cleanup-confirm-remove'
|
||||
|
||||
const listProbe = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('./workspace-cleanup-candidate-list', () => ({
|
||||
WorkspaceCleanupCandidateList: <Row,>({
|
||||
rows,
|
||||
renderRow
|
||||
}: {
|
||||
rows: readonly Row[]
|
||||
renderRow: (row: Row, index: number) => ReactNode
|
||||
}) => {
|
||||
listProbe(rows.length)
|
||||
return <>{rows.slice(0, 5).map(renderRow)}</>
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/scroll-area', () => ({
|
||||
ScrollArea: ({ children }: { children: ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
describe('WorkspaceCleanupConfirmRemove', () => {
|
||||
beforeEach(() => {
|
||||
listProbe.mockClear()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('routes fleet-sized confirmations through the bounded row list', () => {
|
||||
const candidates = Array.from({ length: 2_727 }, (_, index) =>
|
||||
makeFacetCandidate({
|
||||
worktreeId: `repo-1::/repo/worktree-${index}`,
|
||||
displayName: `Workspace ${index}`,
|
||||
path: `/repo/worktree-${index}`
|
||||
})
|
||||
)
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceCleanupConfirmRemove
|
||||
candidates={candidates}
|
||||
reviewInfoByWorktreeId={new Map()}
|
||||
progress={null}
|
||||
onBack={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
expect(listProbe).toHaveBeenCalledWith(candidates.length)
|
||||
expect(container.querySelectorAll('span.truncate.text-sm.font-medium')).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
import type { WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation'
|
||||
import { formatWorkspaceCleanupRelativeTime } from './workspace-cleanup-relative-time'
|
||||
import { StatusPill } from './workspace-cleanup-status-pill'
|
||||
import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list'
|
||||
|
||||
const CONFIRM_REMOVE_ROW_ESTIMATE_PX = 76
|
||||
|
||||
const EMPTY_REVIEW_INFO: WorkspaceCleanupReviewInfo = {
|
||||
hasReview: false,
|
||||
@@ -42,6 +45,7 @@ export function WorkspaceCleanupConfirmRemove({
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}): React.JSX.Element {
|
||||
const [scrollElement, setScrollElement] = React.useState<HTMLDivElement | null>(null)
|
||||
const count = candidates.length
|
||||
const deleting = progress !== null
|
||||
const progressValue = progress
|
||||
@@ -120,15 +124,20 @@ export function WorkspaceCleanupConfirmRemove({
|
||||
)}
|
||||
</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 className="min-h-0 flex-1" viewportRef={setScrollElement}>
|
||||
<WorkspaceCleanupCandidateList
|
||||
rows={candidates}
|
||||
scrollElement={scrollElement}
|
||||
estimatedRowHeight={CONFIRM_REMOVE_ROW_ESTIMATE_PX}
|
||||
renderRow={(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">
|
||||
|
||||
+17
-11
@@ -3,32 +3,38 @@ import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupScanProgress } from '../../../../shared/workspace-cleanup'
|
||||
import { formatWorkspaceCleanupScanProgress } from './workspace-cleanup-scan-notice'
|
||||
|
||||
export function WorkspaceCleanupInitialScanBanner({
|
||||
progress
|
||||
}: {
|
||||
progress: WorkspaceCleanupScanProgress | null
|
||||
}): React.JSX.Element {
|
||||
const title =
|
||||
progress && progress.totalWorktreeCount > 0
|
||||
? translate(
|
||||
'components.workspace.cleanup.scan.progress',
|
||||
'Scanning workspaces ({{value0}}/{{value1}})',
|
||||
{
|
||||
value0: progress.scannedWorktreeCount,
|
||||
value1: progress.totalWorktreeCount
|
||||
}
|
||||
)
|
||||
: translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7eee951968',
|
||||
'Scanning workspaces'
|
||||
)
|
||||
|
||||
return (
|
||||
<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="text-xs font-medium text-foreground">{title}</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.'
|
||||
'Collecting workspace information. You can close this and come back.'
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs font-medium text-muted-foreground">
|
||||
{formatWorkspaceCleanupScanProgress(progress)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,10 @@ import type { LiveAgentWorktreeStatus } from '@/lib/worktree-activity-state'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { Worktree, WorkspaceStatusDefinition } from '../../../../shared/types'
|
||||
import { getWorkspaceStatus } from '../../../../shared/workspace-statuses'
|
||||
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
|
||||
import type {
|
||||
WorkspaceSpaceWorktree,
|
||||
WorkspaceSpaceWorktreeMeasurement
|
||||
} from '../../../../shared/workspace-space-types'
|
||||
import {
|
||||
canSelectWorkspaceCleanupCandidate,
|
||||
type WorkspaceCleanupBlocker,
|
||||
@@ -163,7 +166,10 @@ export function buildWorkspaceCleanupFacetList(
|
||||
|
||||
/** Only `ok` scans carry a trustworthy byte count; everything else stays unsized. */
|
||||
export function buildWorkspaceCleanupSizeIndex(
|
||||
worktrees: readonly WorkspaceSpaceWorktree[] | null | undefined
|
||||
worktrees:
|
||||
| readonly (WorkspaceSpaceWorktree | WorkspaceSpaceWorktreeMeasurement)[]
|
||||
| null
|
||||
| undefined
|
||||
): Map<string, number> {
|
||||
const index = new Map<string, number>()
|
||||
for (const entry of worktrees ?? []) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createDefaultWorkspaceCleanupFilterState } from '../../../../shared/workspace-cleanup-filter-model'
|
||||
import { WorkspaceCleanupFilterBar } from './workspace-cleanup-filter-bar'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('WorkspaceCleanupFilterBar', () => {
|
||||
it('shows determinate size-scan progress', () => {
|
||||
render(
|
||||
<WorkspaceCleanupFilterBar
|
||||
facetProps={{
|
||||
filters: createDefaultWorkspaceCleanupFilterState(),
|
||||
counts: {
|
||||
activity: 0,
|
||||
size: 0,
|
||||
status: 0,
|
||||
agent: 0,
|
||||
git: 0,
|
||||
review: 0,
|
||||
ticket: 0,
|
||||
context: 0,
|
||||
location: 0,
|
||||
safety: 0
|
||||
},
|
||||
totalCount: 100,
|
||||
options: { workspaceStatuses: [], hostIds: [], repos: [], reviewProviders: [] },
|
||||
onPatch: vi.fn()
|
||||
}}
|
||||
activeFacetGroupCount={0}
|
||||
matchedCount={100}
|
||||
hasActiveFilters={false}
|
||||
sizeScan={{
|
||||
measuredCount: 23,
|
||||
scanning: true,
|
||||
scannedCount: 23,
|
||||
totalCount: 100,
|
||||
onRun: vi.fn()
|
||||
}}
|
||||
gitEvidence={{ pendingCount: 0, totalCount: 0 }}
|
||||
onQueryChange={vi.fn()}
|
||||
onClearFilters={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
(screen.getByRole('button', { name: 'Measuring 23/100' }) as HTMLButtonElement).disabled
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,8 @@ import type { WorkspaceCleanupFacetGroupProps } from './workspace-cleanup-facet-
|
||||
export type WorkspaceCleanupSizeScanState = {
|
||||
measuredCount: number
|
||||
scanning: boolean
|
||||
scannedCount: number
|
||||
totalCount: number
|
||||
onRun: () => void
|
||||
}
|
||||
|
||||
@@ -111,20 +113,22 @@ export function WorkspaceCleanupFilterBar({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{sizeScan.measuredCount === 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={sizeScan.scanning}
|
||||
onClick={sizeScan.onRun}
|
||||
>
|
||||
{sizeScan.scanning || sizeScan.measuredCount === 0 ? (
|
||||
<Button variant="outline" size="sm" 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')}
|
||||
{sizeScan.scanning
|
||||
? sizeScan.totalCount > 0
|
||||
? translate(
|
||||
'components.workspace.cleanup.browse.measuringSizesProgress',
|
||||
'Measuring {{value0}}/{{value1}}',
|
||||
{ value0: sizeScan.scannedCount, value1: sizeScan.totalCount }
|
||||
)
|
||||
: translate('components.workspace.cleanup.browse.measuringSizes', 'Measuring sizes')
|
||||
: translate('components.workspace.cleanup.browse.measureSizes', 'Measure sizes')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors'
|
||||
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -52,7 +52,7 @@ export function getWorkspaceCleanupReviewInfo(
|
||||
state: WorkspaceCleanupRendererStateInputs
|
||||
): WorkspaceCleanupReviewInfo {
|
||||
const worktree = getWorktreeMapFromState(state).get(candidate.worktreeId) ?? null
|
||||
const repo = state.repos.find((entry) => entry.id === candidate.repoId) ?? null
|
||||
const repo = getRepoMapFromState(state).get(candidate.repoId) ?? null
|
||||
const hostedReview = getCachedHostedReview(candidate, worktree, repo, state)
|
||||
if (hostedReview) {
|
||||
return {
|
||||
|
||||
@@ -42,6 +42,15 @@ export function matchesWorkspaceCleanupFilters(
|
||||
now: number = Date.now()
|
||||
): boolean {
|
||||
const query = filters.query.trim().toLowerCase()
|
||||
return matchesWorkspaceCleanupFiltersWithQuery(facets, filters, now, query)
|
||||
}
|
||||
|
||||
function matchesWorkspaceCleanupFiltersWithQuery(
|
||||
facets: WorkspaceCleanupFacets,
|
||||
filters: WorkspaceCleanupFilterState,
|
||||
now: number,
|
||||
query: string
|
||||
): boolean {
|
||||
if (query && !facets.searchText.includes(query)) {
|
||||
return false
|
||||
}
|
||||
@@ -64,7 +73,10 @@ export function filterWorkspaceCleanupFacets(
|
||||
filters: WorkspaceCleanupFilterState,
|
||||
now: number = Date.now()
|
||||
): WorkspaceCleanupFacets[] {
|
||||
return facets.filter((entry) => matchesWorkspaceCleanupFilters(entry, filters, now))
|
||||
const query = filters.query.trim().toLowerCase()
|
||||
return facets.filter((entry) =>
|
||||
matchesWorkspaceCleanupFiltersWithQuery(entry, filters, now, query)
|
||||
)
|
||||
}
|
||||
|
||||
export function runWorkspaceCleanupQuery(
|
||||
|
||||
@@ -41,7 +41,7 @@ export function formatWorkspaceCleanupScanProgress(
|
||||
if (!progress || progress.scannedWorktreeCount === 0) {
|
||||
return translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe',
|
||||
'Finding inactive workspaces...'
|
||||
'Finding workspaces...'
|
||||
)
|
||||
}
|
||||
return translate(
|
||||
@@ -52,15 +52,15 @@ export function formatWorkspaceCleanupScanProgress(
|
||||
}
|
||||
|
||||
export function formatWorkspaceCleanupReadyToast(
|
||||
inactiveCount: number,
|
||||
workspaceCount: number,
|
||||
suggestedCount: number
|
||||
): string {
|
||||
if (inactiveCount === 0) {
|
||||
if (workspaceCount === 0) {
|
||||
return 'No workspaces found.'
|
||||
}
|
||||
const workspaceNoun = inactiveCount === 1 ? 'workspace' : 'workspaces'
|
||||
const workspaceNoun = workspaceCount === 1 ? 'workspace' : 'workspaces'
|
||||
const suggestedNoun = suggestedCount === 1 ? 'suggestion' : 'suggestions'
|
||||
return `${inactiveCount} ${workspaceNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.`
|
||||
return `${workspaceCount} ${workspaceNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.`
|
||||
}
|
||||
|
||||
function formatScanErrorRepoName(
|
||||
|
||||
@@ -2580,7 +2580,7 @@
|
||||
"2b31bf68de": "inactive",
|
||||
"ac5ba84cc1": "selected",
|
||||
"8b74d4ea6e": "Scanning worktrees and git state, then combining open tab, terminal, live agent, and remote availability signals before suggesting deletions.",
|
||||
"7eee951968": "Checking workspace safety",
|
||||
"7eee951968": "Scanning workspaces",
|
||||
"191f0bc98e": "Close",
|
||||
"7ae2ad30f4": "Refresh",
|
||||
"e0b5a4deaa": "Review inactive workspaces before deleting their local files and Orca state.",
|
||||
@@ -2596,16 +2596,16 @@
|
||||
"ee81adfcef": "View",
|
||||
"4d0b72481c": "Ignore",
|
||||
"9cc26c019d": "Remove",
|
||||
"0e2d235c63": "Inactive workspace scan ready",
|
||||
"0e2d235c63": "Workspace scan ready",
|
||||
"4a35c08764": "Review",
|
||||
"47123d0108": "Scanning inactive workspaces. You can close this and come back.",
|
||||
"9a3be9f2df": "Scanning inactive workspaces. New rows appear here as they finish. You can close this and come back.",
|
||||
"47123d0108": "Collecting workspace information. You can close this and come back.",
|
||||
"9a3be9f2df": "Scanning workspaces. New rows appear here as they finish. You can close this and come back.",
|
||||
"3d957ff117": "No workspaces match these filters.",
|
||||
"e94b1f8bb4": "Clear filters",
|
||||
"efb3843e75": "Filter and sort workspaces",
|
||||
"93b7381d50": "Filters",
|
||||
"a615e24679": "Sort",
|
||||
"4cc5b73efe": "Finding inactive workspaces...",
|
||||
"4cc5b73efe": "Finding workspaces...",
|
||||
"5bf2e88480": "{{value0}}/{{value1}} {{value2}} scanned",
|
||||
"searchPlaceholder": "Search workspaces",
|
||||
"ageFilter": "Age",
|
||||
@@ -15484,7 +15484,9 @@
|
||||
"notMeasured": "Not measured",
|
||||
"openWorkspaceNamed": "Open {{value0}}",
|
||||
"openWorkspace": "Open workspace",
|
||||
"workspaceStatus": "Workspace status: {{value0}}"
|
||||
"workspaceStatus": "Workspace status: {{value0}}",
|
||||
"measuringSizesProgress": "Measuring {{value0}}/{{value1}}",
|
||||
"measuringSizes": "Measuring sizes"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -87,6 +87,37 @@ function makeAnalysis(): WorkspaceSpaceAnalysis {
|
||||
}
|
||||
|
||||
describe('workspace space slice', () => {
|
||||
it('keeps streamed measurements across later progress updates', () => {
|
||||
const store = createWorkspaceSpaceTestStore()
|
||||
store.setState({ workspaceSpaceScanning: true })
|
||||
const progress = {
|
||||
scanId: 'scan-1',
|
||||
state: 'running' as const,
|
||||
startedAt: 1,
|
||||
updatedAt: 1,
|
||||
totalRepoCount: 1,
|
||||
scannedRepoCount: 0,
|
||||
totalWorktreeCount: 2,
|
||||
scannedWorktreeCount: 1,
|
||||
currentRepoDisplayName: 'orca',
|
||||
currentWorktreeDisplayName: 'feature'
|
||||
}
|
||||
|
||||
store.getState().applyWorkspaceSpaceProgress({
|
||||
...progress,
|
||||
completedMeasurements: [{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }]
|
||||
})
|
||||
store.getState().applyWorkspaceSpaceProgress({
|
||||
...progress,
|
||||
scannedWorktreeCount: 2,
|
||||
currentWorktreeDisplayName: null
|
||||
})
|
||||
|
||||
expect(store.getState().workspaceSpaceMeasurements).toEqual([
|
||||
{ worktreeId: 'a', status: 'ok', sizeBytes: 10 }
|
||||
])
|
||||
})
|
||||
|
||||
it('removes deleted worktrees from cached analysis totals', () => {
|
||||
const store = createWorkspaceSpaceTestStore()
|
||||
store.setState({ workspaceSpaceAnalysis: makeAnalysis() })
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { StateCreator } from 'zustand'
|
||||
import type {
|
||||
WorkspaceSpaceAnalysis,
|
||||
WorkspaceSpaceScanProgress
|
||||
WorkspaceSpaceScanProgress,
|
||||
WorkspaceSpaceWorktreeMeasurement
|
||||
} from '../../../../shared/workspace-space-types'
|
||||
import type { AppState } from '../types'
|
||||
|
||||
@@ -12,6 +13,7 @@ export type WorkspaceSpaceSlice = {
|
||||
workspaceSpaceScanProgress: WorkspaceSpaceScanProgress | null
|
||||
workspaceSpaceScanError: string | null
|
||||
workspaceSpaceScanning: boolean
|
||||
workspaceSpaceMeasurements: WorkspaceSpaceWorktreeMeasurement[]
|
||||
applyWorkspaceSpaceProgress: (progress: WorkspaceSpaceScanProgress) => void
|
||||
cancelWorkspaceSpaceScan: () => Promise<boolean>
|
||||
/** Stale-while-revalidate seed; true when the persisted analysis filled an empty slice. */
|
||||
@@ -74,6 +76,7 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
workspaceSpaceScanProgress: null,
|
||||
workspaceSpaceScanError: null,
|
||||
workspaceSpaceScanning: false,
|
||||
workspaceSpaceMeasurements: [],
|
||||
applyWorkspaceSpaceProgress: (progress) =>
|
||||
set((state) => {
|
||||
if (
|
||||
@@ -82,9 +85,14 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
) {
|
||||
return state
|
||||
}
|
||||
const sameScan = state.workspaceSpaceScanProgress?.scanId === progress.scanId
|
||||
const previousMeasurements = sameScan ? state.workspaceSpaceMeasurements : []
|
||||
return {
|
||||
workspaceSpaceScanProgress: progress,
|
||||
workspaceSpaceScanning: true
|
||||
workspaceSpaceScanning: true,
|
||||
workspaceSpaceMeasurements: progress.completedMeasurements?.length
|
||||
? [...previousMeasurements, ...progress.completedMeasurements]
|
||||
: previousMeasurements
|
||||
}
|
||||
}),
|
||||
cancelWorkspaceSpaceScan: async () => {
|
||||
@@ -134,7 +142,8 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
set({
|
||||
workspaceSpaceScanning: true,
|
||||
workspaceSpaceScanProgress: null,
|
||||
workspaceSpaceScanError: null
|
||||
workspaceSpaceScanError: null,
|
||||
workspaceSpaceMeasurements: []
|
||||
})
|
||||
// Why: the compact Resource Manager card and the full Space page share
|
||||
// one manual scan result; duplicate button presses should join the same IO.
|
||||
@@ -148,7 +157,8 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
set({
|
||||
workspaceSpaceAnalysis: analysis,
|
||||
workspaceSpaceScanning: false,
|
||||
workspaceSpaceScanProgress: null
|
||||
workspaceSpaceScanProgress: null,
|
||||
workspaceSpaceMeasurements: []
|
||||
})
|
||||
return analysis
|
||||
})
|
||||
@@ -158,7 +168,8 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
? null
|
||||
: errorMessage(error),
|
||||
workspaceSpaceScanning: false,
|
||||
workspaceSpaceScanProgress: null
|
||||
workspaceSpaceScanProgress: null,
|
||||
workspaceSpaceMeasurements: []
|
||||
})
|
||||
throw error
|
||||
})
|
||||
@@ -171,15 +182,16 @@ export const createWorkspaceSpaceSlice: StateCreator<AppState, [], [], Workspace
|
||||
if (worktreeIds.length > 0) {
|
||||
get().recordFeatureInteraction?.('workspace-cleanup')
|
||||
}
|
||||
set((state) =>
|
||||
state.workspaceSpaceAnalysis
|
||||
? {
|
||||
workspaceSpaceAnalysis: removeDeletedWorktreesFromAnalysis(
|
||||
state.workspaceSpaceAnalysis,
|
||||
worktreeIds
|
||||
)
|
||||
}
|
||||
: state
|
||||
)
|
||||
set((state) => {
|
||||
const deletedSet = new Set(worktreeIds)
|
||||
return {
|
||||
workspaceSpaceAnalysis: state.workspaceSpaceAnalysis
|
||||
? removeDeletedWorktreesFromAnalysis(state.workspaceSpaceAnalysis, worktreeIds)
|
||||
: null,
|
||||
workspaceSpaceMeasurements: state.workspaceSpaceMeasurements.filter(
|
||||
(measurement) => !deletedSet.has(measurement.worktreeId)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -70,6 +70,11 @@ export type WorkspaceSpaceAnalyzeResult =
|
||||
| { ok: true; analysis: WorkspaceSpaceAnalysis }
|
||||
| { ok: false; cancelled: true }
|
||||
|
||||
export type WorkspaceSpaceWorktreeMeasurement = Pick<
|
||||
WorkspaceSpaceWorktree,
|
||||
'worktreeId' | 'status' | 'sizeBytes'
|
||||
>
|
||||
|
||||
export type WorkspaceSpaceDirectoryScanResult = {
|
||||
sizeBytes: number
|
||||
skippedEntryCount: number
|
||||
@@ -89,4 +94,6 @@ export type WorkspaceSpaceScanProgress = {
|
||||
scannedWorktreeCount: number
|
||||
currentRepoDisplayName: string | null
|
||||
currentWorktreeDisplayName: string | null
|
||||
/** Append-only batch; absent for older senders and progress-only updates. */
|
||||
completedMeasurements?: WorkspaceSpaceWorktreeMeasurement[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user