diff --git a/src/main/git/git-runtime-options.ts b/src/main/git/git-runtime-options.ts index cf897a7b395..ca0155f07f6 100644 --- a/src/main/git/git-runtime-options.ts +++ b/src/main/git/git-runtime-options.ts @@ -1,10 +1,15 @@ export type GitRuntimeOptions = { wslDistro?: string + signal?: AbortSignal } export function gitOptionsForWorktree( cwd: string, options: GitRuntimeOptions = {} -): { cwd: string; wslDistro?: string } { - return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +): { cwd: string; wslDistro?: string; signal?: AbortSignal } { + return { + cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.signal ? { signal: options.signal } : {}) + } } diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index beef7c5e6be..4f455618569 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -836,6 +836,7 @@ type GitStreamOptions = { cwd: string env?: NodeJS.ProcessEnv wslDistro?: string + signal?: AbortSignal /** Byte backstop; defaults to DEFAULT_GIT_MAX_BUFFER. */ maxBuffer?: number /** @@ -863,6 +864,10 @@ export async function gitStreamStdout( const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER return withGitSpan({ args, cwd: options.cwd }, async () => { return new Promise((resolve, reject) => { + if (options.signal?.aborted) { + reject(createAbortError()) + return + } const child = gitSpawn(args, { cwd: options.cwd, env: nonInteractiveGitEnv(options.env), @@ -887,6 +892,7 @@ export async function gitStreamStdout( child.stderr?.off('data', onStderrData) child.off('error', onError) child.off('close', onClose) + options.signal?.removeEventListener('abort', onAbort) // Flush any bytes the decoders were holding for an incomplete sequence. stdoutDecoder.end() stderrDecoder.end() @@ -953,11 +959,19 @@ export async function gitStreamStdout( } finish(new Error(`git exited with ${code}: ${stderr}`)) } + function onAbort(): void { + killSpawnedCommandTree(child) + finish(createAbortError()) + } child.stdout?.on('data', onStdoutData) child.stderr?.on('data', onStderrData) child.on('error', onError) child.on('close', onClose) + options.signal?.addEventListener('abort', onAbort, { once: true }) + if (options.signal?.aborted) { + onAbort() + } }) }) } diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 63917212baa..4902a23e59e 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -109,6 +109,9 @@ export async function getStatus( options: GetStatusOptions = {} ): Promise { gitDiffReadDedupe.clear() + if (options.signal) { + return runGetStatus(worktreePath, options) + } // Why: dedupe only concurrent identical reads; after settle, callers must // execute a fresh status read rather than observing a cached result. const cacheKey = getStatusReadKey(worktreePath, options) @@ -190,6 +193,7 @@ async function runGetStatus( // Why: status polling is read-like; avoid refreshing the index and racing // terminal Git commands on `.git/worktrees/*/index.lock`. env: gitOptionalLocksDisabledEnv(), + signal: options.signal, onStdout: (chunk) => parser.update(chunk, limit) }) if (!stoppedEarly) { diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 714d3189831..62709b5ba38 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -29,6 +29,7 @@ type SparseWorktreeCreateError = Error & { export type GitWorktreeExecOptions = { wslDistro?: string + signal?: AbortSignal } export type AddWorktreeOptions = GitWorktreeExecOptions & { @@ -69,8 +70,12 @@ const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8 function gitExecOptions( cwd: string, options: GitWorktreeExecOptions = {} -): { cwd: string; wslDistro?: string } { - return options.wslDistro ? { cwd, wslDistro: options.wslDistro } : { cwd } +): { cwd: string; wslDistro?: string; signal?: AbortSignal } { + return { + cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.signal ? { signal: options.signal } : {}) + } } function getErrorCode(error: unknown): string | undefined { diff --git a/src/main/ipc/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup-git-evidence.ts index b956eefc3da..90e6058f598 100644 --- a/src/main/ipc/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup-git-evidence.ts @@ -37,7 +37,10 @@ export async function readWorkspaceCleanupGitEvidence( try { status = await withWorkspaceCleanupTimeout( - repo.connectionId ? provider!.getStatus(worktree.path) : getStatus(worktree.path), + (signal) => + repo.connectionId + ? provider!.getStatus(worktree.path, { signal }) + : getStatus(worktree.path, { signal }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out reading git status.' ) @@ -90,11 +93,15 @@ async function readUnpushedCommitCount( ): Promise { try { const result = await withWorkspaceCleanupTimeout( - repo.connectionId - ? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path) - : gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { - cwd: worktree.path - }), + (signal) => + repo.connectionId + ? provider!.exec(['rev-list', '--count', 'HEAD', '--not', '--remotes'], worktree.path, { + signal + }) + : gitExecFileAsync(['rev-list', '--count', 'HEAD', '--not', '--remotes'], { + cwd: worktree.path, + signal + }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out checking unpushed commits.' ) diff --git a/src/main/ipc/workspace-cleanup-scan-primitives.ts b/src/main/ipc/workspace-cleanup-scan-primitives.ts index 4a30fce683b..4f0a84c8a86 100644 --- a/src/main/ipc/workspace-cleanup-scan-primitives.ts +++ b/src/main/ipc/workspace-cleanup-scan-primitives.ts @@ -33,16 +33,20 @@ export async function mapWorkspaceCleanupWithConcurrency( } export async function withWorkspaceCleanupTimeout( - promise: Promise, + run: (signal: AbortSignal) => Promise, timeoutMs: number, message: string ): Promise { + const controller = new AbortController() let timeoutId: NodeJS.Timeout | undefined const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs) + timeoutId = setTimeout(() => { + controller.abort() + reject(new Error(message)) + }, timeoutMs) }) try { - return await Promise.race([promise, timeoutPromise]) + return await Promise.race([run(controller.signal), timeoutPromise]) } finally { if (timeoutId) { clearTimeout(timeoutId) diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index df3c5917a94..33866e6cd73 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -182,7 +182,7 @@ async function listCleanupGitWorktrees( return { provider, gitWorktrees: await withWorkspaceCleanupTimeout( - provider.listWorktrees(repo.path), + (signal) => provider.listWorktrees(repo.path, { signal }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out listing SSH worktrees.' ) @@ -191,7 +191,7 @@ async function listCleanupGitWorktrees( return { provider: null, gitWorktrees: await withWorkspaceCleanupTimeout( - listRepoWorktrees(repo), + (signal) => listRepoWorktrees(repo, { signal }), WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, 'Timed out listing worktrees.' ) @@ -222,11 +222,13 @@ function createProgressEmitter( scannedAt: number, options: WorkspaceCleanupScanOptions ): WorkspaceCleanupProgressEmitter { - const candidates: WorkspaceCleanupCandidate[] = [] const errors: WorkspaceCleanupScanError[] = [] let totalWorktreeCount = 0 let scannedWorktreeCount = 0 - const emit = (): void => { + const emit = ( + candidates: WorkspaceCleanupCandidate[], + candidateMode: WorkspaceCleanupScanProgress['candidateMode'] = 'snapshot' + ): void => { if (!scanId) { return } @@ -235,23 +237,23 @@ function createProgressEmitter( scannedAt, totalWorktreeCount, scannedWorktreeCount, - candidates: [...candidates], - errors: [...errors] + candidates, + errors: [...errors], + candidateMode }) } return { addDiscovered: (count) => { totalWorktreeCount += count - emit() + emit([], 'append') }, addCandidate: (candidate) => { - candidates.push(candidate) scannedWorktreeCount += 1 - emit() + emit([candidate], 'append') }, addErrors: (newErrors) => { appendWorkspaceCleanupItems(errors, newErrors) - emit() + emit([], 'append') } } } diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 7b3a15828eb..6fe5320b1b1 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -10,6 +10,7 @@ import type { Repo, WorktreeMeta } from '../../shared/types' +import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' const { listRepoWorktreesMock, @@ -203,14 +204,23 @@ describe('workspace cleanup scan', () => { totalWorktreeCount: 2, candidates: [] }) + const candidateProgress = progress.filter( + (event): event is WorkspaceCleanupScanProgress => + (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) + const progressWorktreeIds = candidateProgress.flatMap((event) => + event.candidates.map((candidate) => candidate.worktreeId) + ) + expect(progressWorktreeIds).toEqual( + expect.arrayContaining(['repo-1::/repo-feature-a', 'repo-1::/repo-feature-b']) + ) 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' }) - ]) + totalWorktreeCount: 2 }) }) @@ -228,6 +238,30 @@ describe('workspace cleanup scan', () => { ]) }) + it('aborts a hung worktree list when the cleanup timeout fires', async () => { + let signal: AbortSignal | undefined + listRepoWorktreesMock.mockImplementation((_repo: Repo, options?: { signal?: AbortSignal }) => { + signal = options?.signal + return new Promise(() => {}) + }) + + const scan = scanWorkspaceCleanup(makeStore()) + await vi.advanceTimersByTimeAsync(8_000) + + await expect(scan).resolves.toEqual({ + scannedAt: NOW, + candidates: [], + errors: [ + { + repoId: 'repo-1', + repoName: 'Repo', + message: 'Timed out listing worktrees.' + } + ] + }) + expect(signal?.aborted).toBe(true) + }) + it('skips disconnected remote workspaces without a scan warning', async () => { const result = await scanWorkspaceCleanup( makeStore({ @@ -306,8 +340,12 @@ describe('workspace cleanup scan', () => { }) ) - expect(provider.listWorktrees).toHaveBeenCalledWith('/repo') - expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature') + expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', { + signal: expect.any(AbortSignal) + }) + expect(provider.getStatus).toHaveBeenCalledWith('/remote/repo-feature', { + signal: expect.any(AbortSignal) + }) expect(result.errors).toEqual([]) expect(result.candidates[0]).toMatchObject({ connectionId: 'ssh-1', @@ -330,7 +368,9 @@ describe('workspace cleanup scan', () => { }) ) - expect(provider.listWorktrees).toHaveBeenCalledWith('/repo') + expect(provider.listWorktrees).toHaveBeenCalledWith('/repo', { + signal: expect.any(AbortSignal) + }) expect(result.errors).toEqual([]) expect(result.candidates).toEqual([]) }) @@ -389,7 +429,9 @@ describe('workspace cleanup scan', () => { ) expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1) - expect(listRepoWorktreesMock).toHaveBeenCalledWith(repoTwo) + expect(listRepoWorktreesMock).toHaveBeenCalledWith(repoTwo, { + signal: expect.any(AbortSignal) + }) expect(result.candidates).toHaveLength(1) expect(result.candidates[0]).toMatchObject({ worktreeId: 'repo-2::/repo-two-feature', @@ -440,7 +482,7 @@ describe('workspace cleanup scan', () => { expect(getStatusMock).toHaveBeenCalledTimes(1) expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['rev-list', '--count', 'HEAD', '--not', '--remotes'], - { cwd: '/repo-feature' } + { cwd: '/repo-feature', signal: expect.any(AbortSignal) } ) expect(result.candidates[0]).toMatchObject({ tier: 'ready', diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 751ebc1ef7c..d70eec2f365 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -103,11 +103,15 @@ export class SshGitProvider implements IGitProvider { const upstreamCacheBypassArgs = options?.bypassEffectiveUpstreamNegativeCache ? { bypassEffectiveUpstreamNegativeCache: true } : {} - return (await this.mux.request('git.status', { - worktreePath, - ...includeIgnoredArgs, - ...upstreamCacheBypassArgs - })) as GitStatusResult + return (await this.mux.request( + 'git.status', + { + worktreePath, + ...includeIgnoredArgs, + ...upstreamCacheBypassArgs + }, + { signal: options?.signal } + )) as GitStatusResult } async checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise { diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index c8a090e59fa..3a3ebdd94a4 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -168,6 +168,7 @@ export type IFilesystemProvider = { export type GitProviderStatusOptions = { includeIgnored?: boolean bypassEffectiveUpstreamNegativeCache?: boolean + signal?: AbortSignal } export type IGitProvider = { diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index ca3bcf359c9..e02ba35f9c7 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -6,6 +6,7 @@ import { areWorktreePathsEqual } from './ipc/worktree-logic' type LocalRepoWorktreeListOptions = { wslDistro?: string + signal?: AbortSignal } export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean { diff --git a/src/renderer/src/store/slices/workspace-cleanup.test.ts b/src/renderer/src/store/slices/workspace-cleanup.test.ts index ebbd303d99b..c0713d3bbe2 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.test.ts @@ -185,6 +185,83 @@ describe('workspace cleanup viewed rows', () => { }) }) + it('does not re-probe previously enriched rows during cumulative progress updates', async () => { + const pending = deferred() + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockResolvedValue(false) + const getForegroundProcess = vi.fn().mockResolvedValue('zsh') + ;( + globalThis.window as unknown as { + api: { + pty?: { + hasChildProcesses: typeof hasChildProcesses + getForegroundProcess: typeof getForegroundProcess + } + } + } + ).api.pty = { hasChildProcesses, getForegroundProcess } + const store = createCleanupTestStore() + store.setState({ + tabsByWorktree: { + 'repo1::/tmp/terminal': [ + { id: 'tab-1', title: 'zsh' } + ] as AppState['tabsByWorktree'][string] + }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } as Partial) + + const scanPromise = store.getState().scanWorkspaceCleanup() + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1) + }) + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [laterCandidate], + errors: [], + candidateMode: 'append' + }) + + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2) + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + + pending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, laterCandidate], + errors: [] + }) + await scanPromise + + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + }) + it('does not join broad cleanup scans with different explicit args', async () => { const firstPending = deferred() const secondPending = deferred() diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 86c6cdba37e..db188ac5ee2 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -63,6 +63,12 @@ type EnrichOptions = { applyDismissals?: boolean } +type WorkspaceCleanupEnrichmentCacheEntry = { + inputSignature: string + localSignature: string + candidate: WorkspaceCleanupCandidate +} + const RECENT_VISIBLE_CONTEXT_MS = 24 * 60 * 60 * 1000 const VIEWED_FROM_CLEANUP_MS = 2 * 60 * 60 * 1000 const WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY = 4 @@ -72,6 +78,10 @@ let inFlightWorkspaceCleanupScan: { promise: Promise } | null = null let latestWorkspaceCleanupScanToken = 0 +let workspaceCleanupEnrichmentCache: { + scanToken: number + entries: Map +} | null = null const SHELL_PROCESS_NAMES = new Set([ 'bash', @@ -144,11 +154,16 @@ export const createWorkspaceCleanupSlice: StateCreator { const scan = await window.api.workspaceCleanup.scan(scanArgs, (progress) => { void applyWorkspaceCleanupProgress(progress, scanToken, get, set) }) - const enriched = await enrichWorkspaceCleanupCandidates(scan.candidates, get()) + const enriched = await enrichWorkspaceCleanupCandidatesForScan( + scan.candidates, + get(), + scanToken + ) const result = { ...scan, candidates: enriched } if (scanToken === latestWorkspaceCleanupScanToken) { set({ @@ -315,20 +330,81 @@ async function applyWorkspaceCleanupProgress( replace?: false ) => void ): Promise { - const enriched = await enrichWorkspaceCleanupCandidates(progress.candidates, getState()) + const state = getState() + const previousCandidates = + progress.candidateMode === 'append' && + state.workspaceCleanupProgress?.scanId === progress.scanId + ? state.workspaceCleanupProgress.candidates + : [] + const enrichedProgressCandidates = await enrichWorkspaceCleanupCandidatesForScan( + progress.candidates, + state, + scanToken + ) + const candidates = + progress.candidateMode === 'append' + ? appendWorkspaceCleanupProgressCandidates(previousCandidates, enrichedProgressCandidates) + : enrichedProgressCandidates if (scanToken !== latestWorkspaceCleanupScanToken) { return } - setState({ - workspaceCleanupScan: { - scannedAt: progress.scannedAt, - candidates: enriched, - errors: progress.errors - }, - workspaceCleanupProgress: { ...progress, candidates: enriched } + setState((state) => { + if ( + state.workspaceCleanupProgress?.scanId === progress.scanId && + state.workspaceCleanupProgress.scannedWorktreeCount > progress.scannedWorktreeCount + ) { + return {} + } + return { + workspaceCleanupScan: { + scannedAt: progress.scannedAt, + candidates, + errors: progress.errors + }, + workspaceCleanupProgress: { ...progress, candidates } + } }) } +async function enrichWorkspaceCleanupCandidatesForScan( + candidates: readonly WorkspaceCleanupCandidate[], + state: AppState, + scanToken: number +): Promise { + if (workspaceCleanupEnrichmentCache?.scanToken !== scanToken) { + workspaceCleanupEnrichmentCache = { scanToken, entries: new Map() } + } + return enrichWorkspaceCleanupCandidatesWithCache( + candidates, + state, + workspaceCleanupEnrichmentCache.entries + ) +} + +function appendWorkspaceCleanupProgressCandidates( + previousCandidates: readonly WorkspaceCleanupCandidate[], + nextCandidates: readonly WorkspaceCleanupCandidate[] +): WorkspaceCleanupCandidate[] { + if (nextCandidates.length === 0) { + return [...previousCandidates] + } + + const indexesByWorktreeId = new Map( + previousCandidates.map((candidate, index) => [candidate.worktreeId, index]) + ) + const merged = [...previousCandidates] + for (const candidate of nextCandidates) { + const existingIndex = indexesByWorktreeId.get(candidate.worktreeId) + if (existingIndex === undefined) { + indexesByWorktreeId.set(candidate.worktreeId, merged.length) + merged.push(candidate) + continue + } + merged[existingIndex] = candidate + } + return merged +} + async function mapWithConcurrency( items: readonly T[], limit: number, @@ -404,6 +480,106 @@ export async function enrichWorkspaceCleanupCandidates( ) } +async function enrichWorkspaceCleanupCandidatesWithCache( + candidates: readonly WorkspaceCleanupCandidate[], + state: AppState, + cache: Map, + options: EnrichOptions = {} +): Promise { + return Promise.all( + candidates.map(async (candidate) => { + const inputSignature = getWorkspaceCleanupCandidateInputSignature(candidate) + const localSignature = getWorkspaceCleanupLocalStateSignature( + candidate.worktreeId, + state, + options + ) + const cached = cache.get(candidate.worktreeId) + if (cached?.inputSignature === inputSignature && cached.localSignature === localSignature) { + return cached.candidate + } + + const enriched = await enrichWorkspaceCleanupCandidate(candidate, state, options) + cache.set(candidate.worktreeId, { + inputSignature, + localSignature, + candidate: enriched + }) + return enriched + }) + ) +} + +function getWorkspaceCleanupCandidateInputSignature(candidate: WorkspaceCleanupCandidate): string { + return JSON.stringify({ + fingerprint: candidate.fingerprint, + blockers: candidate.blockers, + reasons: candidate.reasons, + git: candidate.git, + lastActivityAt: candidate.lastActivityAt, + createdAt: candidate.createdAt, + path: candidate.path, + branch: candidate.branch + }) +} + +function getWorkspaceCleanupLocalStateSignature( + worktreeId: string, + state: AppState, + options: EnrichOptions +): string { + const tabs = state.tabsByWorktree[worktreeId] ?? [] + const tabIds = tabs.map((tab) => tab.id) + const tabIdSet = new Set(tabIds) + const openFiles = state.openFiles + .filter((file) => file.worktreeId === worktreeId) + .map((file) => ({ + id: file.id, + isDirty: file.isDirty, + hasDraft: state.editorDrafts[file.id] !== undefined + })) + const retainedDoneAgentPaneKeys = Object.entries(state.retainedAgentsByPaneKey) + .filter(([, entry]) => entry.worktreeId === worktreeId && entry.entry.state === 'done') + .map(([paneKey]) => paneKey) + .sort() + const agentStatuses = Object.values(state.agentStatusByPaneKey) + .filter((entry) => tabIdSet.has(getPaneKeyTabId(entry.paneKey))) + .map((entry) => ({ + paneKey: entry.paneKey, + state: entry.state, + updatedAt: entry.updatedAt + })) + .sort((a, b) => a.paneKey.localeCompare(b.paneKey)) + const ptyIdsByTabId = Object.fromEntries( + tabIds.map((tabId) => [tabId, state.ptyIdsByTabId[tabId] ?? []]) + ) + const runtimePaneTitlesByTabId = Object.fromEntries( + tabIds.map((tabId) => [tabId, state.runtimePaneTitlesByTabId[tabId] ?? {}]) + ) + const terminalLayoutsByTabId = Object.fromEntries( + tabIds.map((tabId) => [tabId, state.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId ?? {}]) + ) + const dismissal = + options.applyDismissals === false + ? null + : (state.workspaceCleanupDismissals[worktreeId] ?? null) + + return JSON.stringify({ + active: state.activeWorktreeId === worktreeId, + tabs: tabs.map((tab) => ({ id: tab.id, title: tab.title })), + ptyIdsByTabId, + runtimePaneTitlesByTabId, + terminalLayoutsByTabId, + openFiles, + browserTabCount: (state.browserTabsByWorktree[worktreeId] ?? []).length, + retainedDoneAgentPaneKeys, + agentStatuses, + lastVisitedAt: state.lastVisitedAtByWorktreeId[worktreeId] ?? 0, + viewed: state.workspaceCleanupViewedCandidates[worktreeId] ?? null, + dismissal + }) +} + async function enrichWorkspaceCleanupCandidate( candidate: WorkspaceCleanupCandidate, state: AppState, diff --git a/src/shared/workspace-cleanup.ts b/src/shared/workspace-cleanup.ts index 0837f57cda8..46ae7d1dd15 100644 --- a/src/shared/workspace-cleanup.ts +++ b/src/shared/workspace-cleanup.ts @@ -99,6 +99,7 @@ export type WorkspaceCleanupScanProgress = WorkspaceCleanupScanResult & { scanId: string scannedWorktreeCount: number totalWorktreeCount: number + candidateMode?: 'append' | 'snapshot' } export type WorkspaceCleanupLocalProcessResult = {