mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
Stream workspace cleanup scan progress
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -171,6 +171,49 @@ describe('workspace cleanup scan', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports cleanup scan progress as inactive workspaces are checked', async () => {
|
||||
listRepoWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/repo-feature-a',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature-a',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: '/repo-feature-b',
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/feature-b',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const progress: unknown[] = []
|
||||
|
||||
const result = await scanWorkspaceCleanup(
|
||||
makeStore(),
|
||||
{ scanId: 'scan-1' },
|
||||
{ onProgress: (event) => progress.push(event) }
|
||||
)
|
||||
|
||||
expect(result.candidates).toHaveLength(2)
|
||||
expect(progress[0]).toMatchObject({
|
||||
scanId: 'scan-1',
|
||||
scannedWorktreeCount: 0,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: []
|
||||
})
|
||||
expect(progress.at(-1)).toMatchObject({
|
||||
scanId: 'scan-1',
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: expect.arrayContaining([
|
||||
expect.objectContaining({ worktreeId: 'repo-1::/repo-feature-a' }),
|
||||
expect.objectContaining({ worktreeId: 'repo-1::/repo-feature-b' })
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps raw scan errors out of renderer-facing results', async () => {
|
||||
listRepoWorktreesMock.mockRejectedValue(new Error('fatal: path /Users/alice/private failed'))
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type WorkspaceCleanupReason,
|
||||
type WorkspaceCleanupScanError,
|
||||
type WorkspaceCleanupScanArgs,
|
||||
type WorkspaceCleanupScanProgress,
|
||||
type WorkspaceCleanupScanResult
|
||||
} from '../../shared/workspace-cleanup'
|
||||
|
||||
@@ -55,6 +56,16 @@ type WorkspaceCleanupHandlerDeps = {
|
||||
getLocalPtyProvider?: () => IPtyProvider
|
||||
}
|
||||
|
||||
type WorkspaceCleanupScanOptions = {
|
||||
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
}
|
||||
|
||||
type WorkspaceCleanupScanRepoProgress = {
|
||||
onWorktreesDiscovered?: (count: number) => void
|
||||
onCandidateScanned?: (candidate: WorkspaceCleanupCandidate) => void
|
||||
onErrors?: (errors: WorkspaceCleanupScanError[]) => void
|
||||
}
|
||||
|
||||
export function registerWorkspaceCleanupHandlers(
|
||||
store: Store,
|
||||
deps: WorkspaceCleanupHandlerDeps = {}
|
||||
@@ -66,8 +77,12 @@ export function registerWorkspaceCleanupHandlers(
|
||||
|
||||
ipcMain.handle(
|
||||
'workspaceCleanup:scan',
|
||||
(_event, args?: WorkspaceCleanupScanArgs): Promise<WorkspaceCleanupScanResult> =>
|
||||
scanWorkspaceCleanup(store, args ?? {})
|
||||
(event, args?: WorkspaceCleanupScanArgs): Promise<WorkspaceCleanupScanResult> =>
|
||||
scanWorkspaceCleanup(store, args ?? {}, {
|
||||
onProgress: args?.scanId
|
||||
? (progress) => event.sender.send('workspaceCleanup:scanProgress', progress)
|
||||
: undefined
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle('workspaceCleanup:dismiss', (_event, args: WorkspaceCleanupDismissArgs) => {
|
||||
@@ -192,7 +207,8 @@ function isPathWithin(candidatePath: string, parentPath: string): boolean {
|
||||
|
||||
export async function scanWorkspaceCleanup(
|
||||
store: Store,
|
||||
args: WorkspaceCleanupScanArgs = {}
|
||||
args: WorkspaceCleanupScanArgs = {},
|
||||
options: WorkspaceCleanupScanOptions = {}
|
||||
): Promise<WorkspaceCleanupScanResult> {
|
||||
const scannedAt = Date.now()
|
||||
const parsedTarget = args.worktreeId ? splitWorktreeId(args.worktreeId) : null
|
||||
@@ -204,6 +220,23 @@ export async function scanWorkspaceCleanup(
|
||||
: store.getRepos()
|
||||
const errors: WorkspaceCleanupScanResult['errors'] = []
|
||||
const candidates: WorkspaceCleanupCandidate[] = []
|
||||
const progressCandidates: WorkspaceCleanupCandidate[] = []
|
||||
const progressErrors: WorkspaceCleanupScanError[] = []
|
||||
let totalWorktreeCount = 0
|
||||
let scannedWorktreeCount = 0
|
||||
const emitProgress = (): void => {
|
||||
if (!args.scanId) {
|
||||
return
|
||||
}
|
||||
options.onProgress?.({
|
||||
scanId: args.scanId,
|
||||
scannedAt,
|
||||
totalWorktreeCount,
|
||||
scannedWorktreeCount,
|
||||
candidates: [...progressCandidates],
|
||||
errors: [...progressErrors]
|
||||
})
|
||||
}
|
||||
|
||||
for (const repo of repos) {
|
||||
const result = await scanRepoWorkspaces({
|
||||
@@ -211,7 +244,20 @@ export async function scanWorkspaceCleanup(
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeId: args.worktreeId,
|
||||
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? [])
|
||||
skipGitWorktreeIds: new Set(args.skipGitWorktreeIds ?? []),
|
||||
onWorktreesDiscovered: (count) => {
|
||||
totalWorktreeCount += count
|
||||
emitProgress()
|
||||
},
|
||||
onCandidateScanned: (candidate) => {
|
||||
progressCandidates.push(candidate)
|
||||
scannedWorktreeCount += 1
|
||||
emitProgress()
|
||||
},
|
||||
onErrors: (repoErrors) => {
|
||||
appendListItems(progressErrors, repoErrors)
|
||||
emitProgress()
|
||||
}
|
||||
})
|
||||
appendListItems(candidates, result.candidates)
|
||||
appendListItems(errors, result.errors)
|
||||
@@ -220,14 +266,25 @@ export async function scanWorkspaceCleanup(
|
||||
return { scannedAt, candidates, errors }
|
||||
}
|
||||
|
||||
async function scanRepoWorkspaces(args: {
|
||||
store: Store
|
||||
repo: Repo
|
||||
scannedAt: number
|
||||
targetWorktreeId?: string
|
||||
skipGitWorktreeIds: Set<string>
|
||||
}): Promise<WorkspaceCleanupScanResult> {
|
||||
const { store, repo, scannedAt, targetWorktreeId, skipGitWorktreeIds } = args
|
||||
async function scanRepoWorkspaces(
|
||||
args: {
|
||||
store: Store
|
||||
repo: Repo
|
||||
scannedAt: number
|
||||
targetWorktreeId?: string
|
||||
skipGitWorktreeIds: Set<string>
|
||||
} & WorkspaceCleanupScanRepoProgress
|
||||
): Promise<WorkspaceCleanupScanResult> {
|
||||
const {
|
||||
store,
|
||||
repo,
|
||||
scannedAt,
|
||||
targetWorktreeId,
|
||||
skipGitWorktreeIds,
|
||||
onWorktreesDiscovered,
|
||||
onCandidateScanned,
|
||||
onErrors
|
||||
} = args
|
||||
const errors: WorkspaceCleanupScanResult['errors'] = []
|
||||
let provider: IGitProvider | null = null
|
||||
let gitWorktrees: GitWorktreeInfo[] = []
|
||||
@@ -241,11 +298,16 @@ async function scanRepoWorkspaces(args: {
|
||||
if (!provider) {
|
||||
// Why: cleanup should reflect only workspaces Orca can currently
|
||||
// inspect. Disconnected SSH repos are skipped in broad scans.
|
||||
const candidates = targetWorktreeId
|
||||
? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId)
|
||||
: []
|
||||
onWorktreesDiscovered?.(candidates.length)
|
||||
for (const candidate of candidates) {
|
||||
onCandidateScanned?.(candidate)
|
||||
}
|
||||
return {
|
||||
scannedAt,
|
||||
candidates: targetWorktreeId
|
||||
? synthesizeDisconnectedSshCandidates(store, repo, scannedAt, targetWorktreeId)
|
||||
: [],
|
||||
candidates,
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
@@ -269,6 +331,7 @@ async function scanRepoWorkspaces(args: {
|
||||
return { scannedAt, candidates: [], errors: [] }
|
||||
}
|
||||
errors.push(createScanError(repo, toSafeRepoScanError(error)))
|
||||
onErrors?.(errors)
|
||||
return { scannedAt, candidates: [], errors }
|
||||
}
|
||||
|
||||
@@ -289,18 +352,26 @@ async function scanRepoWorkspaces(args: {
|
||||
)
|
||||
})
|
||||
|
||||
const candidates = await mapWithConcurrency(worktrees, WORKTREE_SCAN_CONCURRENCY, (worktree) =>
|
||||
buildCandidate({
|
||||
repo,
|
||||
worktree,
|
||||
scannedAt,
|
||||
provider,
|
||||
skipGit: skipGitWorktreeIds.has(worktree.id),
|
||||
forceGitCheck: Boolean(targetWorktreeId)
|
||||
}).catch((error) => {
|
||||
console.error('Workspace cleanup candidate scan failed', error)
|
||||
return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error))
|
||||
})
|
||||
onWorktreesDiscovered?.(worktrees.length)
|
||||
|
||||
const candidates = await mapWithConcurrency(
|
||||
worktrees,
|
||||
WORKTREE_SCAN_CONCURRENCY,
|
||||
async (worktree) => {
|
||||
const candidate = await buildCandidate({
|
||||
repo,
|
||||
worktree,
|
||||
scannedAt,
|
||||
provider,
|
||||
skipGit: skipGitWorktreeIds.has(worktree.id),
|
||||
forceGitCheck: Boolean(targetWorktreeId)
|
||||
}).catch((error) => {
|
||||
console.error('Workspace cleanup candidate scan failed', error)
|
||||
return buildCandidateFromError(repo, worktree, scannedAt, toErrorMessage(error))
|
||||
})
|
||||
onCandidateScanned?.(candidate)
|
||||
return candidate
|
||||
}
|
||||
)
|
||||
|
||||
return { scannedAt, candidates, errors }
|
||||
|
||||
@@ -391,6 +391,7 @@ import type {
|
||||
WorkspaceCleanupLocalProcessArgs,
|
||||
WorkspaceCleanupLocalProcessResult,
|
||||
WorkspaceCleanupScanArgs,
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../shared/workspace-cleanup'
|
||||
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
|
||||
@@ -989,7 +990,10 @@ export type PreloadApi = {
|
||||
) => () => void
|
||||
}
|
||||
workspaceCleanup: {
|
||||
scan: (args?: WorkspaceCleanupScanArgs) => Promise<WorkspaceCleanupScanResult>
|
||||
scan: (
|
||||
args?: WorkspaceCleanupScanArgs,
|
||||
onProgress?: (progress: WorkspaceCleanupScanProgress) => void
|
||||
) => Promise<WorkspaceCleanupScanResult>
|
||||
dismiss: (args: WorkspaceCleanupDismissArgs) => Promise<void>
|
||||
clearDismissals: () => Promise<void>
|
||||
hasKillableLocalProcesses: (
|
||||
|
||||
+19
-1
@@ -79,6 +79,7 @@ import type {
|
||||
RateLimitState
|
||||
} from '../shared/rate-limit-types'
|
||||
import type { WorkspaceSpaceScanProgress } from '../shared/workspace-space-types'
|
||||
import type { WorkspaceCleanupScanProgress } from '../shared/workspace-cleanup'
|
||||
import type { WorkspacePortAdvertisedUrlChangedEvent } from '../shared/workspace-ports'
|
||||
import type { GhAuthDiagnostic } from '../shared/github-auth-types'
|
||||
import type { TaskSourceContext } from '../shared/task-source-context'
|
||||
@@ -662,7 +663,24 @@ const api = {
|
||||
} satisfies PreloadApi['worktrees'],
|
||||
|
||||
workspaceCleanup: {
|
||||
scan: (args) => ipcRenderer.invoke('workspaceCleanup:scan', args),
|
||||
scan: (args, onProgress) => {
|
||||
if (!onProgress) {
|
||||
return ipcRenderer.invoke('workspaceCleanup:scan', args)
|
||||
}
|
||||
const scanId = args?.scanId ?? crypto.randomUUID()
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
progress: WorkspaceCleanupScanProgress
|
||||
): void => {
|
||||
if (progress.scanId === scanId) {
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
ipcRenderer.on('workspaceCleanup:scanProgress', listener)
|
||||
return ipcRenderer
|
||||
.invoke('workspaceCleanup:scan', { ...args, scanId })
|
||||
.finally(() => ipcRenderer.removeListener('workspaceCleanup:scanProgress', listener))
|
||||
},
|
||||
dismiss: (args) => ipcRenderer.invoke('workspaceCleanup:dismiss', args),
|
||||
clearDismissals: () => ipcRenderer.invoke('workspaceCleanup:clearDismissals'),
|
||||
hasKillableLocalProcesses: (args) =>
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import RepoMultiCombobox from '@/components/ui/repo-multi-combobox'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
type WorkspaceCleanupBlocker,
|
||||
type WorkspaceCleanupCandidate,
|
||||
type WorkspaceCleanupScanError,
|
||||
type WorkspaceCleanupScanProgress,
|
||||
type WorkspaceCleanupTier
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
@@ -192,6 +192,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
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)
|
||||
@@ -407,7 +408,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
const protectedCount = groups.protected.length
|
||||
const inactiveCount = filteredCandidates.length
|
||||
const hasAnyCandidates = candidates.length > 0
|
||||
const initialLoading = loading && !scan
|
||||
const initialLoading = loading && !hasAnyCandidates
|
||||
const activeBaseRows =
|
||||
resolvedActiveView === 'hidden' ? hiddenCandidates : groups[resolvedActiveView]
|
||||
const activeRows = useMemo(
|
||||
@@ -670,7 +671,9 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
'Scanning worktrees and git state, then combining open tab, terminal, live agent, and remote availability signals before suggesting deletions.'
|
||||
)}
|
||||
</div>
|
||||
<ScanProgress value={35} className="mt-2" />
|
||||
<div className="mt-1 text-xs font-medium text-muted-foreground">
|
||||
{formatWorkspaceCleanupProgress(scanProgress)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : hasAnyCandidates ? (
|
||||
@@ -721,7 +724,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
<StatusPill>
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.946e8b0eb1',
|
||||
'Refreshing'
|
||||
'Scanning'
|
||||
)}
|
||||
</StatusPill>
|
||||
) : null}
|
||||
@@ -754,18 +757,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && scan ? (
|
||||
{loading && scan && hasAnyCandidates ? (
|
||||
<div className="border-b border-border bg-muted/25 px-5 py-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4dc32d56c',
|
||||
'Refreshing inactive workspaces. You can leave this modal; the last result stays visible.'
|
||||
'Scanning inactive workspaces. New rows appear here as they finish.'
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatWorkspaceCleanupProgress(scanProgress)}
|
||||
</span>
|
||||
</div>
|
||||
<ScanProgress value={70} className="mt-2" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1002,22 +1007,6 @@ function StatusPill({
|
||||
)
|
||||
}
|
||||
|
||||
function ScanProgress({
|
||||
value,
|
||||
className
|
||||
}: {
|
||||
value: number
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Progress
|
||||
value={value}
|
||||
aria-label="Workspace cleanup scan progress"
|
||||
className={cn('h-1 bg-muted', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceCleanupFilterToolbar({
|
||||
filters,
|
||||
sortKey,
|
||||
@@ -1763,6 +1752,25 @@ function formatWorkspaceCleanupReadyToastDescription(
|
||||
return `${inactiveCount} inactive ${inactiveNoun} found, with ${suggestedCount} cleanup ${suggestedNoun}.`
|
||||
}
|
||||
|
||||
function formatWorkspaceCleanupProgress(progress: WorkspaceCleanupScanProgress | null): string {
|
||||
if (!progress || progress.totalWorktreeCount === 0) {
|
||||
return translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4cc5b73efe',
|
||||
'Finding inactive workspaces...'
|
||||
)
|
||||
}
|
||||
const noun = progress.totalWorktreeCount === 1 ? 'workspace' : 'workspaces'
|
||||
return translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.5bf2e88480',
|
||||
'{{value0}}/{{value1}} {{value2}} scanned',
|
||||
{
|
||||
value0: progress.scannedWorktreeCount,
|
||||
value1: progress.totalWorktreeCount,
|
||||
value2: noun
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function SkeletonRows(): React.JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '../types'
|
||||
import type {
|
||||
WorkspaceCleanupCandidate,
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { createWorkspaceCleanupSlice, enrichWorkspaceCleanupCandidates } from './workspace-cleanup'
|
||||
@@ -135,6 +136,55 @@ describe('workspace cleanup viewed rows', () => {
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('shows scanned cleanup candidates before the final broad scan resolves', async () => {
|
||||
const pending = deferred<WorkspaceCleanupScanResult>()
|
||||
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
|
||||
const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' })
|
||||
const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' })
|
||||
const scan = vi.fn((_args, progressCallback) => {
|
||||
onProgress = progressCallback
|
||||
return pending.promise
|
||||
})
|
||||
installWorkspaceCleanupApi(scan)
|
||||
const store = createCleanupTestStore()
|
||||
|
||||
const scanPromise = store.getState().scanWorkspaceCleanup()
|
||||
onProgress?.({
|
||||
scanId: 'scan-1',
|
||||
scannedAt: NOW,
|
||||
scannedWorktreeCount: 1,
|
||||
totalWorktreeCount: 2,
|
||||
candidates: [partialCandidate],
|
||||
errors: []
|
||||
})
|
||||
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(true)
|
||||
await vi.waitFor(() => {
|
||||
expect(store.getState().workspaceCleanupProgress).toMatchObject({
|
||||
scannedWorktreeCount: 1,
|
||||
totalWorktreeCount: 2
|
||||
})
|
||||
})
|
||||
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate])
|
||||
|
||||
pending.resolve({
|
||||
scannedAt: NOW,
|
||||
candidates: [partialCandidate, finalCandidate],
|
||||
errors: []
|
||||
})
|
||||
|
||||
await expect(scanPromise).resolves.toEqual({
|
||||
scannedAt: NOW,
|
||||
candidates: [partialCandidate, finalCandidate],
|
||||
errors: []
|
||||
})
|
||||
expect(store.getState().workspaceCleanupLoading).toBe(false)
|
||||
expect(store.getState().workspaceCleanupProgress).toMatchObject({
|
||||
scannedWorktreeCount: 2,
|
||||
totalWorktreeCount: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('does not join broad cleanup scans with different explicit args', async () => {
|
||||
const firstPending = deferred<WorkspaceCleanupScanResult>()
|
||||
const secondPending = deferred<WorkspaceCleanupScanResult>()
|
||||
@@ -436,9 +486,12 @@ describe('workspace cleanup viewed rows', () => {
|
||||
|
||||
await store.getState().scanWorkspaceCleanup()
|
||||
|
||||
expect(scan).toHaveBeenCalledWith({
|
||||
skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace'])
|
||||
})
|
||||
expect(scan).toHaveBeenCalledWith(
|
||||
{
|
||||
skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace'])
|
||||
},
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not defer git checks for focused remove preflights', async () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type WorkspaceCleanupCandidate,
|
||||
type WorkspaceCleanupDismissal,
|
||||
type WorkspaceCleanupScanArgs,
|
||||
type WorkspaceCleanupScanProgress,
|
||||
type WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status'
|
||||
@@ -42,6 +43,7 @@ type WorkspaceCleanupViewedCandidate = {
|
||||
|
||||
export type WorkspaceCleanupSlice = {
|
||||
workspaceCleanupScan: WorkspaceCleanupScanResult | null
|
||||
workspaceCleanupProgress: WorkspaceCleanupScanProgress | null
|
||||
workspaceCleanupLoading: boolean
|
||||
workspaceCleanupError: string | null
|
||||
workspaceCleanupDismissals: Record<string, WorkspaceCleanupDismissal>
|
||||
@@ -105,6 +107,7 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
get
|
||||
) => ({
|
||||
workspaceCleanupScan: null,
|
||||
workspaceCleanupProgress: null,
|
||||
workspaceCleanupLoading: false,
|
||||
workspaceCleanupError: null,
|
||||
workspaceCleanupDismissals: {},
|
||||
@@ -135,14 +138,31 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
|
||||
return inFlightWorkspaceCleanupScan.promise
|
||||
}
|
||||
|
||||
set({ workspaceCleanupLoading: true, workspaceCleanupError: null })
|
||||
set({
|
||||
workspaceCleanupLoading: true,
|
||||
workspaceCleanupProgress: null,
|
||||
workspaceCleanupError: null
|
||||
})
|
||||
const scanToken = ++latestWorkspaceCleanupScanToken
|
||||
const promise = (async () => {
|
||||
const scan = await window.api.workspaceCleanup.scan(scanArgs)
|
||||
const scan = await window.api.workspaceCleanup.scan(scanArgs, (progress) => {
|
||||
void applyWorkspaceCleanupProgress(progress, scanToken, get, set)
|
||||
})
|
||||
const enriched = await enrichWorkspaceCleanupCandidates(scan.candidates, get())
|
||||
const result = { ...scan, candidates: enriched }
|
||||
if (scanToken === latestWorkspaceCleanupScanToken) {
|
||||
set({ workspaceCleanupScan: result, workspaceCleanupLoading: false })
|
||||
set({
|
||||
workspaceCleanupScan: result,
|
||||
workspaceCleanupProgress: {
|
||||
scanId: get().workspaceCleanupProgress?.scanId ?? '',
|
||||
scannedAt: result.scannedAt,
|
||||
scannedWorktreeCount: result.candidates.length,
|
||||
totalWorktreeCount: result.candidates.length,
|
||||
candidates: result.candidates,
|
||||
errors: result.errors
|
||||
},
|
||||
workspaceCleanupLoading: false
|
||||
})
|
||||
}
|
||||
return result
|
||||
})()
|
||||
@@ -286,6 +306,29 @@ function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string {
|
||||
})
|
||||
}
|
||||
|
||||
async function applyWorkspaceCleanupProgress(
|
||||
progress: WorkspaceCleanupScanProgress,
|
||||
scanToken: number,
|
||||
getState: () => AppState,
|
||||
setState: (
|
||||
partial: Partial<AppState> | ((state: AppState) => Partial<AppState>),
|
||||
replace?: false
|
||||
) => void
|
||||
): Promise<void> {
|
||||
const enriched = await enrichWorkspaceCleanupCandidates(progress.candidates, getState())
|
||||
if (scanToken !== latestWorkspaceCleanupScanToken) {
|
||||
return
|
||||
}
|
||||
setState({
|
||||
workspaceCleanupScan: {
|
||||
scannedAt: progress.scannedAt,
|
||||
candidates: enriched,
|
||||
errors: progress.errors
|
||||
},
|
||||
workspaceCleanupProgress: { ...progress, candidates: enriched }
|
||||
})
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
|
||||
@@ -74,6 +74,7 @@ export type WorkspaceCleanupCandidate = {
|
||||
export type WorkspaceCleanupScanArgs = {
|
||||
worktreeId?: string
|
||||
skipGitWorktreeIds?: string[]
|
||||
scanId?: string
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupLocalProcessArgs = {
|
||||
@@ -94,6 +95,12 @@ export type WorkspaceCleanupScanResult = {
|
||||
errors: WorkspaceCleanupScanError[]
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupScanProgress = WorkspaceCleanupScanResult & {
|
||||
scanId: string
|
||||
scannedWorktreeCount: number
|
||||
totalWorktreeCount: number
|
||||
}
|
||||
|
||||
export type WorkspaceCleanupLocalProcessResult = {
|
||||
hasKillableProcesses: boolean | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user