diff --git a/src/main/ipc/workspace-cleanup-activity.test.ts b/src/main/ipc/workspace-cleanup-activity.test.ts index 43f28cca033..72f20291f4a 100644 --- a/src/main/ipc/workspace-cleanup-activity.test.ts +++ b/src/main/ipc/workspace-cleanup-activity.test.ts @@ -55,6 +55,87 @@ describe('resolveWorkspaceCleanupActivityWorktree', () => { expect(worktree.lastActivityAt).toBe(20_000) }) + it('uses linked worktree gitdir metadata when the .git pointer is stale', async () => { + const gitDirPath = path.join('/repo', '.git', 'worktrees', 'repo-feature') + const gitDirHeadPath = path.join(gitDirPath, 'HEAD') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + '/repo-feature': 10_000, + [path.join('/repo-feature', '.git')]: 20_000, + [gitDirPath]: 50_000, + [gitDirHeadPath]: 60_000, + [gitDirLogsHeadPath]: 70_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => `gitdir: ${gitDirPath}\n`) + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(path.join('/repo-feature', '.git')) + expect(statPath).toHaveBeenCalledWith(gitDirPath) + expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(worktree.lastActivityAt).toBe(70_000) + }) + + it('resolves relative linked worktree gitdir pointers from the worktree path', async () => { + const gitDirPath = path.resolve('/repo-feature', '.repo/gitdir') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => ({ + mtimeMs: targetPath === gitDirLogsHeadPath ? 40_000 : 10_000 + })) + const readTextFile = vi.fn(async () => 'gitdir: .repo/gitdir\n') + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree(), + statPath, + readTextFile + ) + + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(worktree.lastActivityAt).toBe(40_000) + }) + + it('converts WSL linked worktree gitdir pointers before reading metadata', async () => { + const worktreePath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo-feature` + const gitDirPath = String.raw`\\wsl.localhost\Ubuntu\home\me\repo\.git\worktrees\repo-feature` + const gitDirHeadPath = path.join(gitDirPath, 'HEAD') + const gitDirLogsHeadPath = path.join(gitDirPath, 'logs', 'HEAD') + const statPath = vi.fn(async (targetPath: string) => { + const mtimes: Record = { + [worktreePath]: 10_000, + [path.join(worktreePath, '.git')]: 20_000, + [gitDirPath]: 50_000, + [gitDirHeadPath]: 60_000, + [gitDirLogsHeadPath]: 70_000 + } + return { mtimeMs: mtimes[targetPath] ?? 0 } + }) + const readTextFile = vi.fn(async () => 'gitdir: /home/me/repo/.git/worktrees/repo-feature\n') + + const worktree = await resolveWorkspaceCleanupActivityWorktree( + REPO, + makeWorktree({ path: worktreePath }), + statPath, + readTextFile + ) + + expect(readTextFile).toHaveBeenCalledWith(path.join(worktreePath, '.git')) + expect(statPath).toHaveBeenCalledWith(gitDirPath) + expect(statPath).toHaveBeenCalledWith(gitDirHeadPath) + expect(statPath).toHaveBeenCalledWith(gitDirLogsHeadPath) + expect(statPath).not.toHaveBeenCalledWith('/home/me/repo/.git/worktrees/repo-feature') + expect(worktree.lastActivityAt).toBe(70_000) + }) + it('keeps persisted activity when it is newer than local metadata', async () => { const statPath = vi.fn(async () => ({ mtimeMs: 10_000 })) diff --git a/src/main/ipc/workspace-cleanup-activity.ts b/src/main/ipc/workspace-cleanup-activity.ts index 545ddda60fd..4b0cffa42a8 100644 --- a/src/main/ipc/workspace-cleanup-activity.ts +++ b/src/main/ipc/workspace-cleanup-activity.ts @@ -1,15 +1,19 @@ -import { lstat } from 'node:fs/promises' +import { lstat, readFile } from 'node:fs/promises' import path from 'node:path' import type { Repo, Worktree } from '../../shared/types' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { toWindowsWslPath } from '../wsl' type StatPath = (targetPath: string) => Promise<{ mtimeMs: number }> +type ReadTextFile = (targetPath: string) => Promise export async function resolveWorkspaceCleanupActivityWorktree( repo: Repo, worktree: Worktree, - statPath: StatPath = statLocalPath + statPath: StatPath = statLocalPath, + readTextFile: ReadTextFile = readLocalTextFile ): Promise { - const activityAt = await resolveWorkspaceCleanupActivityAt(repo, worktree, statPath) + const activityAt = await resolveWorkspaceCleanupActivityAt(repo, worktree, statPath, readTextFile) if (activityAt <= worktree.lastActivityAt) { return worktree } @@ -21,10 +25,15 @@ async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> { return { mtimeMs: Number(stats.mtimeMs) } } +async function readLocalTextFile(targetPath: string): Promise { + return readFile(targetPath, 'utf8') +} + async function resolveWorkspaceCleanupActivityAt( repo: Repo, worktree: Worktree, - statPath: StatPath + statPath: StatPath, + readTextFile: ReadTextFile ): Promise { const persistedActivityAt = Number.isFinite(worktree.lastActivityAt) ? worktree.lastActivityAt : 0 const createdAt = Number.isFinite(worktree.createdAt) ? (worktree.createdAt ?? 0) : 0 @@ -32,21 +41,63 @@ async function resolveWorkspaceCleanupActivityAt( return Math.max(persistedActivityAt, createdAt) } - const filesystemActivityAt = await getNewestLocalWorktreeStatMtime(worktree.path, statPath) + const filesystemActivityAt = await getNewestLocalWorktreeStatMtime( + worktree.path, + statPath, + readTextFile + ) return Math.max(persistedActivityAt, createdAt, filesystemActivityAt) } async function getNewestLocalWorktreeStatMtime( worktreePath: string, - statPath: StatPath + statPath: StatPath, + readTextFile: ReadTextFile ): Promise { + const gitPath = path.join(worktreePath, '.git') + const gitDirPath = await readLocalWorktreeGitDir(worktreePath, gitPath, readTextFile) + const gitDirMtimePromises = gitDirPath + ? [ + readMtime(gitDirPath, statPath), + readMtime(path.join(gitDirPath, 'HEAD'), statPath), + readMtime(path.join(gitDirPath, 'logs', 'HEAD'), statPath) + ] + : [] const mtimes = await Promise.all([ readMtime(worktreePath, statPath), - readMtime(path.join(worktreePath, '.git'), statPath) + readMtime(gitPath, statPath), + ...gitDirMtimePromises ]) return Math.max(0, ...mtimes) } +async function readLocalWorktreeGitDir( + worktreePath: string, + gitPath: string, + readTextFile: ReadTextFile +): Promise { + try { + const contents = await readTextFile(gitPath) + const match = /^gitdir:\s*(.+)\s*$/im.exec(contents) + if (!match) { + return null + } + const gitDir = match[1]?.trim() + if (!gitDir) { + return null + } + // Why: linked worktrees keep mutable git state outside the worktree; the + // pointer file mtime alone can miss recent external commits. + const wslWorktree = parseWslUncPath(worktreePath) + if (wslWorktree && gitDir.startsWith('/')) { + return toWindowsWslPath(gitDir, wslWorktree.distro) + } + return path.isAbsolute(gitDir) ? gitDir : path.resolve(worktreePath, gitDir) + } catch { + return null + } +} + async function readMtime(targetPath: string, statPath: StatPath): Promise { try { const stats = await statPath(targetPath) diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index c1dddd2706b..2dc0b0e11a4 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -3,7 +3,7 @@ import { listRepoWorktrees, createFolderWorktree } from '../repo-worktrees' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import type { IGitProvider } from '../providers/types' import { isFolderRepo } from '../../shared/repo-kind' -import type { GitWorktreeInfo, Repo } from '../../shared/types' +import type { GitWorktreeInfo, Repo, Worktree } from '../../shared/types' import { mergeWorktree } from './worktree-logic' import { splitWorktreeId } from '../../shared/worktree-id' import type { @@ -130,49 +130,85 @@ async function scanRepoWorkspaces( const meta = store.getWorktreeMeta(worktreeId) return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) }) - // Why: externally-created worktrees can miss Orca activity stamps; local - // filesystem metadata is a conservative guard before suggesting deletion. - const worktreesWithActivity = await mapWorkspaceCleanupWithConcurrency( - mergedWorktrees, - WORKTREE_SCAN_CONCURRENCY, - (worktree) => resolveWorkspaceCleanupActivityWorktree(repo, worktree) - ) - const worktrees = worktreesWithActivity.filter((worktree) => { - if (targetWorktreeId) { - return worktree.id === targetWorktreeId - } - return ( - !repoIsFolder && - !worktree.isMainWorktree && - isWorkspaceInactiveForCleanup(worktree, scannedAt) - ) - }) - - onWorktreesDiscovered?.(worktrees.length) - - const candidates = await mapWorkspaceCleanupWithConcurrency( - worktrees, + const candidateWorktrees = targetWorktreeId + ? mergedWorktrees.filter((worktree) => worktree.id === targetWorktreeId) + : mergedWorktrees.filter((worktree) => + shouldResolveBroadWorkspaceCleanupActivity(repoIsFolder, worktree, scannedAt) + ) + const candidatesWithSkipped = await mapWorkspaceCleanupWithConcurrency( + candidateWorktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { + // Why: externally-created worktrees can miss Orca activity stamps; local + // filesystem metadata is a conservative guard before suggesting deletion. + const worktreeWithActivity = await resolveCleanupActivityWithTimeout(repo, worktree) + if (!targetWorktreeId && !isWorkspaceInactiveForCleanup(worktreeWithActivity, scannedAt)) { + return null + } + onWorktreesDiscovered?.(1) const candidate = await buildWorkspaceCleanupCandidate({ repo, - worktree, + worktree: worktreeWithActivity, scannedAt, provider, - skipGit: skipGitWorktreeIds.has(worktree.id), + skipGit: skipGitWorktreeIds.has(worktreeWithActivity.id), forceGitCheck: Boolean(targetWorktreeId) }).catch((error) => { console.error('Workspace cleanup candidate scan failed', error) - return buildWorkspaceCleanupCandidateFromError(repo, worktree, scannedAt) + return buildWorkspaceCleanupCandidateFromError(repo, worktreeWithActivity, scannedAt) }) onCandidateScanned?.(candidate) return candidate } ) + const candidates = candidatesWithSkipped.filter( + (candidate): candidate is WorkspaceCleanupCandidate => candidate !== null + ) return { scannedAt, candidates, errors } } +async function resolveCleanupActivityWithTimeout( + repo: Repo, + worktree: Worktree +): Promise { + try { + return await withWorkspaceCleanupTimeout( + () => resolveWorkspaceCleanupActivityWorktree(repo, worktree), + WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS, + 'Timed out reading worktree activity.' + ) + } catch (error) { + console.warn('Workspace cleanup activity scan failed', error) + return worktree + } +} + +function shouldResolveBroadWorkspaceCleanupActivity( + repoIsFolder: boolean, + worktree: Worktree, + scannedAt: number +): boolean { + if (repoIsFolder || worktree.isMainWorktree) { + return false + } + return isWorkspaceInactiveForCleanup( + { + isArchived: worktree.isArchived, + lastActivityAt: getPersistedWorkspaceCleanupActivityAt(worktree) + }, + scannedAt + ) +} + +function getPersistedWorkspaceCleanupActivityAt( + worktree: Pick +): number { + const lastActivityAt = Number.isFinite(worktree.lastActivityAt) ? worktree.lastActivityAt : 0 + const createdAt = Number.isFinite(worktree.createdAt) ? (worktree.createdAt ?? 0) : 0 + return Math.max(lastActivityAt, createdAt) +} + async function listCleanupGitWorktrees( repo: Repo, repoIsFolder: boolean diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index af084713a0b..ffd2c59bd1d 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -1,3 +1,4 @@ +import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' @@ -11,6 +12,8 @@ import type { import type { WorkspaceCleanupScanProgress } from '../../shared/workspace-cleanup' const { + lstatMock, + readFileMock, listRepoWorktreesMock, getStatusMock, gitExecFileAsyncMock, @@ -18,6 +21,8 @@ const { getSshPtyProviderMock, listRegisteredPtysMock } = vi.hoisted(() => ({ + lstatMock: vi.fn(), + readFileMock: vi.fn(), listRepoWorktreesMock: vi.fn(), getStatusMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), @@ -26,6 +31,11 @@ const { listRegisteredPtysMock: vi.fn() })) +vi.mock('node:fs/promises', () => ({ + lstat: lstatMock, + readFile: readFileMock +})) + vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), @@ -92,6 +102,22 @@ function buildWorktreeIds(repoId: string, count: number): string[] { return worktreeIds } +function makeWorktreeMeta(overrides: Partial = {}): WorktreeMeta { + return { + displayName: 'Feature', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: NOW, + ...overrides + } +} + function makeStore( options: { baseRef?: string @@ -123,6 +149,8 @@ describe('workspace cleanup scan', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(NOW) + lstatMock.mockReset() + readFileMock.mockReset() listRepoWorktreesMock.mockReset() getStatusMock.mockReset() gitExecFileAsyncMock.mockReset() @@ -130,6 +158,8 @@ describe('workspace cleanup scan', () => { getSshPtyProviderMock.mockReset() listRegisteredPtysMock.mockReset() listRegisteredPtysMock.mockReturnValue([]) + lstatMock.mockResolvedValue({ mtimeMs: 0 }) + readFileMock.mockRejectedValue(new Error('not a gitdir pointer')) vi.mocked(ipcMain.handle).mockReset() vi.mocked(ipcMain.removeHandler).mockReset() listRepoWorktreesMock.mockResolvedValue([ @@ -199,7 +229,7 @@ describe('workspace cleanup scan', () => { expect(progress[0]).toMatchObject({ scanId: 'scan-1', scannedWorktreeCount: 0, - totalWorktreeCount: 2, + totalWorktreeCount: 1, candidates: [] }) const candidateProgress = progress.filter( @@ -222,6 +252,55 @@ describe('workspace cleanup scan', () => { }) }) + it('emits ready rows while another activity metadata read is stalled', 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 + } + ]) + lstatMock.mockImplementation((targetPath: string) => { + if (targetPath.startsWith('/repo-feature-a')) { + return new Promise(() => undefined) + } + return Promise.resolve({ mtimeMs: 0 }) + }) + const progress: WorkspaceCleanupScanProgress[] = [] + + const scanPromise = scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + + await vi.waitFor(() => { + expect( + progress.some((event) => event.candidates[0]?.worktreeId === 'repo-1::/repo-feature-b') + ).toBe(true) + }) + expect(progress.at(-1)).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + + await vi.advanceTimersByTimeAsync(8_000) + const result = await scanPromise + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual( + expect.arrayContaining(['repo-1::/repo-feature-a', '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')) @@ -273,18 +352,10 @@ describe('workspace cleanup scan', () => { it('uses direct metadata lookup for focused disconnected remote preflight', async () => { const targetWorktreeId = 'repo-1::/remote/repo-feature' - const targetMeta: WorktreeMeta = { + const targetMeta = makeWorktreeMeta({ displayName: 'Remote Feature', - comment: '', - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000 - } + }) const getWorktreeMeta = vi.fn((worktreeId: string) => worktreeId === targetWorktreeId ? targetMeta : undefined ) @@ -313,6 +384,35 @@ describe('workspace cleanup scan', () => { }) }) + it('stats only the requested worktree during focused local preflight scans', 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 result = await scanWorkspaceCleanup(makeStore(), { + worktreeId: 'repo-1::/repo-feature-b' + }) + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]?.worktreeId).toBe('repo-1::/repo-feature-b') + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(lstatMock).toHaveBeenCalledWith('/repo-feature-b') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-feature-b', '.git')) + }) + it('scans connected remote workspaces through the SSH git provider', async () => { const provider = { listWorktrees: vi.fn().mockResolvedValue([ @@ -385,6 +485,95 @@ describe('workspace cleanup scan', () => { expect(result.candidates).toEqual([]) }) + it('stats only broad-scan rows that remain possible cleanup candidates', async () => { + listRepoWorktreesMock.mockResolvedValue([ + { + path: '/repo', + head: 'main123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '/repo-old', + head: 'old123', + branch: 'refs/heads/old', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-recent', + head: 'recent123', + branch: 'refs/heads/recent', + isBare: false, + isMainWorktree: false + }, + { + path: '/repo-new-created', + head: 'created123', + branch: 'refs/heads/created', + isBare: false, + isMainWorktree: false + } + ]) + const metadataByWorktreeId: Record = { + 'repo-1::/repo-old': makeWorktreeMeta({ + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }), + 'repo-1::/repo-recent': makeWorktreeMeta({ + lastActivityAt: NOW - 2 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }), + 'repo-1::/repo-new-created': makeWorktreeMeta({ + createdAt: NOW - 1_000, + lastActivityAt: NOW - 40 * 24 * 60 * 60 * 1000, + baseRef: 'origin/main' + }) + } + const store = { + ...makeStore(), + getWorktreeMeta: (worktreeId: string) => metadataByWorktreeId[worktreeId] + } as unknown as Store + + const result = await scanWorkspaceCleanup(store) + + expect(result.candidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo-1::/repo-old' + ]) + expect(lstatMock).toHaveBeenCalledTimes(2) + expect(lstatMock).toHaveBeenCalledWith('/repo-old') + expect(lstatMock).toHaveBeenCalledWith(path.join('/repo-old', '.git')) + expect(getStatusMock).toHaveBeenCalledTimes(1) + }) + + it('falls back when broad-scan activity metadata stalls', async () => { + lstatMock.mockReturnValue(new Promise(() => undefined)) + const progress: unknown[] = [] + + const scanPromise = scanWorkspaceCleanup( + makeStore(), + { scanId: 'scan-1' }, + { onProgress: (event) => progress.push(event) } + ) + await vi.advanceTimersByTimeAsync(8_000) + + const result = await scanPromise + + expect(result.candidates).toHaveLength(1) + expect(result.candidates[0]?.worktreeId).toBe('repo-1::/repo-feature') + expect(progress[0]).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 0, + totalWorktreeCount: 1 + }) + expect(progress.at(-1)).toMatchObject({ + scanId: 'scan-1', + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + }) + it('includes focused remove preflight rows even when they are recent', async () => { const result = await scanWorkspaceCleanup( makeStore({ diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index 10dc05beff0..dfcb25d7a41 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -73,7 +73,8 @@ import { getCandidateStatus, getContextPillLabel, getDirtyGitLabel, - getReviewPillTone + getReviewPillTone, + shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data' import { StatusPill } from './workspace-cleanup-status-pill' import { @@ -81,6 +82,7 @@ import { type WorkspaceCleanupView, type WorkspaceCleanupViewCounts } from './workspace-cleanup-view-selection' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' import { translate } from '@/i18n/i18n' const DEFAULT_FILTERS: WorkspaceCleanupFilters = { @@ -195,6 +197,16 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const removeCandidates = useAppStore((s) => s.removeWorkspaceCleanupCandidates) const markWorktreesQueuedForDeletion = useAppStore((s) => s.markWorktreesQueuedForDeletion) const clearWorktreeDeleteState = useAppStore((s) => s.clearWorktreeDeleteState) + const deletingWorktreeIds = useAppStore( + useShallow( + (s) => + new Set( + Object.entries(s.deleteStateByWorktreeId) + .filter(([, state]) => state.isDeleting) + .map(([worktreeId]) => worktreeId) + ) + ) + ) const open = activeModal === 'workspace-cleanup' const openRef = useRef(open) @@ -292,16 +304,14 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setFilters(DEFAULT_FILTERS) setSortKey('activity') setSortDirection('asc') - if (scan) { - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates)) - } + setSelectedIds(new Set()) } } if (!loading && !autoScanAttemptedForOpenRef.current) { autoScanAttemptedForOpenRef.current = true startWorkspaceCleanupScan({ notifyWhenReady: true }) } - }, [loading, open, scan, startWorkspaceCleanupScan]) + }, [loading, open, startWorkspaceCleanupScan]) useEffect(() => { if (!open) { @@ -335,17 +345,17 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { }, [candidates, effectiveRepoSelection, eligibleRepoIds.length]) useEffect(() => { - if (!scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { + if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) { return } selectedDefaultsScanAtRef.current = scan.scannedAt if (removalInFlightRef.current) { return } - setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates)) + setSelectedIds(getDefaultSelectedWorkspaceCleanupIds(scan.candidates, deletingWorktreeIds)) setConfirming(false) setRowFailures({}) - }, [scan]) + }, [deletingWorktreeIds, loading, scan]) const visibleCandidates = useMemo(() => { const rows = filteredCandidates.filter((candidate) => !candidate.blockers.includes('dismissed')) @@ -427,9 +437,11 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { .map((id) => byId.get(id)) .filter( (candidate): candidate is WorkspaceCleanupCandidate => - candidate != null && canQueueWorkspaceCleanupCandidate(candidate) + candidate != null && + canQueueWorkspaceCleanupCandidate(candidate) && + !deletingWorktreeIds.has(candidate.worktreeId) ) - }, [activeRows, selectedIds]) + }, [activeRows, deletingWorktreeIds, selectedIds]) useEffect(() => { if (!open || confirming) { return @@ -437,10 +449,12 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { // Why: destructive selection must stay scoped to the rows the user can // currently review after tier/filter changes. setSelectedIds((current) => { - const next = new Set([...current].filter((id) => activeRowIds.has(id))) + const next = new Set( + [...current].filter((id) => activeRowIds.has(id) && !deletingWorktreeIds.has(id)) + ) return next.size === current.size ? current : next }) - }, [activeRowIds, confirming, open]) + }, [activeRowIds, confirming, deletingWorktreeIds, open]) const handleOpenChange = useCallback( (nextOpen: boolean) => { @@ -489,7 +503,14 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { }, []) const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { - setConfirmCandidates([...candidates]) + const nextCandidates = filterWorkspaceCleanupRemovalCandidates( + candidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (nextCandidates.length === 0) { + return + } + setConfirmCandidates(nextCandidates) setConfirming(true) }, []) @@ -506,11 +527,20 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { if (confirmCandidates.length === 0 || removalInFlightRef.current) { return } + const removableCandidates = filterWorkspaceCleanupRemovalCandidates( + confirmCandidates, + useAppStore.getState().deleteStateByWorktreeId + ) + if (removableCandidates.length === 0) { + setConfirming(false) + setConfirmCandidates([]) + return + } removalInFlightRef.current = true setRowFailures({}) - markWorktreesQueuedForDeletion(confirmCandidates.map((candidate) => candidate.worktreeId)) + markWorktreesQueuedForDeletion(removableCandidates.map((candidate) => candidate.worktreeId)) startWorkspaceCleanupBackgroundRemoval({ - candidates: confirmCandidates, + candidates: removableCandidates, removeCandidates, onProgress: (progress) => { if (mountedRef.current) { @@ -521,7 +551,14 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const nextFailures: Record = {} for (const failure of result.failures) { nextFailures[failure.worktreeId] = failure.message - clearWorktreeDeleteState(failure.worktreeId) + const deleteState = useAppStore.getState().deleteStateByWorktreeId[failure.worktreeId] + if ( + deleteState?.isDeleting && + deleteState.error === null && + deleteState.phase === 'queued' + ) { + clearWorktreeDeleteState(failure.worktreeId) + } } if (mountedRef.current) { setRowFailures(nextFailures) @@ -541,7 +578,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { removalInFlightRef.current = false }, onError: () => { - for (const candidate of confirmCandidates) { + for (const candidate of removableCandidates) { clearWorktreeDeleteState(candidate.worktreeId) } if (mountedRef.current) { @@ -666,7 +703,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { variant="destructive" size="sm" onClick={() => openConfirmRemove(selectedCandidates)} - disabled={selectedCount === 0} + disabled={selectedCount === 0 || loading} > {translate( @@ -811,7 +848,12 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { last={activeRows.length > 1 && index === activeRows.length - 1} expanded={expandedRowIds.has(candidate.worktreeId)} lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)} - selected={selectedIds.has(candidate.worktreeId)} + removing={loading || deletingWorktreeIds.has(candidate.worktreeId)} + selected={ + selectedIds.has(candidate.worktreeId) && + !loading && + !deletingWorktreeIds.has(candidate.worktreeId) + } failure={rowFailures[candidate.worktreeId]} onToggleExpanded={toggleExpandedRow} onToggleSelected={(id) => @@ -820,6 +862,9 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { onView={closeAndView} onIgnore={ignoreCandidate} onRemove={(candidate) => { + if (loading) { + return + } setSelectedIds(new Set([candidate.worktreeId])) openConfirmRemove([candidate]) }} @@ -1174,7 +1219,6 @@ function ConfirmRemove({ onConfirm: () => void }): React.JSX.Element { const count = candidates.length - const noun = count === 1 ? 'workspace' : 'workspaces' const deleting = progress !== null const progressValue = progress ? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100)) @@ -1194,14 +1238,16 @@ function ConfirmRemove({
{deleting - ? `${translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e50c4da067', - 'Deleting' - )} ${count} ${noun}` - : `${translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.cbf2f664e2', - 'Delete' - )} ${count} ${noun}?`} + ? translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deletingCount', + 'Deleting workspaces: {{value0}}', + { value0: count } + ) + : translate( + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.deleteCount', + 'Delete workspaces: {{value0}}?', + { value0: count } + )} {deleting @@ -1243,10 +1289,10 @@ function ConfirmRemove({ ) : null}
- {count} {noun}{' '} {translate( - 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.dba753e94f', - 'to delete' + 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.selectedForDeletionCount', + 'Selected for deletion: {{value0}}', + { value0: count } )}
@@ -1283,10 +1329,10 @@ function ConfirmRemove({ ) : null} @@ -1306,6 +1352,7 @@ function ConfirmRemoveRow({ const dirtyLabel = getDirtyGitLabel(candidate) const branchDiffersFromName = candidate.branch !== candidate.displayName const contextPillLabel = getContextPillLabel(candidate) + const showGitMetadataChip = shouldShowGitMetadataChip(candidate) const status = getCandidateStatus(candidate) return (
@@ -1323,7 +1370,9 @@ function ConfirmRemoveRow({ {reviewInfo.label} ) : null} {contextPillLabel ? {contextPillLabel} : null} - {dirtyLabel ? {dirtyLabel} : null} + {dirtyLabel && showGitMetadataChip ? ( + {dirtyLabel} + ) : null}
{candidate.repoName} @@ -1367,11 +1416,14 @@ function hasActiveWorkspaceCleanupPanelControls( } function getDefaultSelectedWorkspaceCleanupIds( - candidates: readonly WorkspaceCleanupCandidate[] + candidates: readonly WorkspaceCleanupCandidate[], + deletingWorktreeIds: ReadonlySet = new Set() ): Set { return new Set( candidates - .filter((candidate) => candidate.selectedByDefault) + .filter( + (candidate) => candidate.selectedByDefault && !deletingWorktreeIds.has(candidate.worktreeId) + ) .map((candidate) => candidate.worktreeId) ) } @@ -1417,13 +1469,11 @@ function formatWorkspaceCleanupProgress(progress: WorkspaceCleanupScanProgress | 'Finding inactive workspaces...' ) } - const noun = progress.scannedWorktreeCount === 1 ? 'workspace' : 'workspaces' return translate( 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.7b7bde5181', - 'Checked {{value0}} {{value1}} so far', + 'Checked workspaces so far: {{value0}}', { - value0: progress.scannedWorktreeCount, - value1: noun + value0: progress.scannedWorktreeCount } ) } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts index b0b1576db43..c072961666b 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { toast } from 'sonner' import { startWorkspaceCleanupBackgroundRemoval, @@ -13,7 +13,21 @@ vi.mock('sonner', () => ({ } })) +async function settleBackgroundRemoval(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + describe('startWorkspaceCleanupBackgroundRemoval', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + }) + it('reports deletion progress while the slow removal promise is pending', async () => { let resolveRemoval: ( result: Awaited> @@ -47,8 +61,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { expect(onResult).not.toHaveBeenCalled() resolveRemoval!({ removedIds: [candidate.worktreeId], failures: [] }) - await Promise.resolve() - await Promise.resolve() + await settleBackgroundRemoval() expect(onProgress).toHaveBeenLastCalledWith({ totalCount: 1, @@ -79,8 +92,7 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { removeCandidates, onProgress }) - await Promise.resolve() - await Promise.resolve() + await settleBackgroundRemoval() expect(removeCandidates).toHaveBeenNthCalledWith(1, [first.worktreeId]) expect(removeCandidates).toHaveBeenNthCalledWith(2, [second.worktreeId]) @@ -92,6 +104,132 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) }) + it('removes nested candidates before their parent workspace', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + const removeCandidates = vi.fn(async (worktreeIds: readonly string[]) => ({ + removedIds: [...worktreeIds], + failures: [] + })) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [child.worktreeId]) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId]) + }) + + it('skips an ancestor after a nested workspace removal fails', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent', + displayName: 'parent', + branch: 'parent', + path: 'C:\\repo\\parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent\\child', + displayName: 'child', + branch: 'child', + path: 'C:\\repo\\parent\\child' + }) + const removeCandidates = vi.fn().mockResolvedValueOnce({ + removedIds: [], + failures: [{ worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }] + }) + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress, + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenCalledTimes(1) + expect(removeCandidates).toHaveBeenCalledWith([child.worktreeId]) + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 2, + processedCount: 2, + removedCount: 0, + failedCount: 2 + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + failures: [ + { worktreeId: child.worktreeId, displayName: child.displayName, message: 'busy' }, + { + worktreeId: parent.worktreeId, + displayName: parent.displayName, + message: 'Skipped because a nested workspace could not be removed.' + } + ] + }) + }) + + it('does not skip same-path ancestors from another connection after a nested failure', async () => { + const failedChild = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child', + connectionId: 'ssh-a' + }) + const unrelatedParent = makeCandidate({ + worktreeId: 'repo-2::/repo/parent', + repoId: 'repo-2', + repoName: 'Repo 2', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent', + connectionId: 'ssh-b' + }) + const removeCandidates = vi + .fn() + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { + worktreeId: failedChild.worktreeId, + displayName: failedChild.displayName, + message: 'busy' + } + ] + }) + .mockResolvedValueOnce({ removedIds: [unrelatedParent.worktreeId], failures: [] }) + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [unrelatedParent, failedChild], + removeCandidates, + onProgress: vi.fn(), + onResult + }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(1, [failedChild.worktreeId]) + expect(removeCandidates).toHaveBeenNthCalledWith(2, [unrelatedParent.worktreeId]) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [unrelatedParent.worktreeId], + failures: [{ worktreeId: failedChild.worktreeId, displayName: 'child', message: 'busy' }] + }) + }) + it('reports removal failures after dismissing the pending toast', async () => { const candidate = makeCandidate() @@ -105,12 +243,110 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }), onProgress: vi.fn() }) - await Promise.resolve() - await Promise.resolve() + await settleBackgroundRemoval() expect(toast.error).toHaveBeenCalledWith( - '1 workspace could not be removed', + 'Workspaces not removed: 1', expect.objectContaining({ description: 'busy' }) ) }) + + it('times out a stalled row removal and continues reporting progress', async () => { + vi.useFakeTimers() + const candidate = makeCandidate() + const onProgress = vi.fn() + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn( + () => + new Promise< + Awaited> + >(() => undefined) + ), + onProgress, + onResult, + removalTimeoutMs: 5 + }) + + await vi.advanceTimersByTimeAsync(5) + await settleBackgroundRemoval() + + expect(onProgress).toHaveBeenLastCalledWith({ + totalCount: 1, + processedCount: 1, + removedCount: 0, + failedCount: 1 + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + failures: [ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: 'Timed out removing alpha.' + } + ] + }) + }) + + it('keeps removal outcome toasts when the result callback throws', async () => { + const candidate = makeCandidate() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn().mockResolvedValue({ + removedIds: [candidate.worktreeId], + failures: [] + }), + onProgress: vi.fn(), + onResult: () => { + throw new Error('callback failed') + }, + onError: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.success).toHaveBeenCalledWith('Removed workspaces: 1') + expect(toast.error).not.toHaveBeenCalledWith( + 'Workspace cleanup failed', + expect.objectContaining({ description: 'callback failed' }) + ) + }) + + it('shows every failure message in the failure toast description', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates: vi + .fn() + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { worktreeId: first.worktreeId, displayName: first.displayName, message: 'busy' } + ] + }) + .mockResolvedValueOnce({ + removedIds: [], + failures: [ + { worktreeId: second.worktreeId, displayName: second.displayName, message: 'dirty' } + ] + }), + onProgress: vi.fn() + }) + await settleBackgroundRemoval() + + expect(toast.error).toHaveBeenCalledWith( + 'Workspaces not removed: 2', + expect.objectContaining({ description: 'busy; dirty' }) + ) + }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index 6ae7b2af864..daa7ee0d3af 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -1,11 +1,17 @@ import { toast } from 'sonner' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../../../shared/cross-platform-path' import type { WorkspaceCleanupFailure, WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' import { translate } from '@/i18n/i18n' +const DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS = 120_000 + export type WorkspaceCleanupRemovalProgress = { totalCount: number processedCount: number @@ -19,6 +25,7 @@ export type WorkspaceCleanupBackgroundRemovalArgs = { onProgress: (progress: WorkspaceCleanupRemovalProgress) => void onResult?: (result: WorkspaceCleanupRemoveResult) => void onError?: (error: unknown) => void + removalTimeoutMs?: number } export function startWorkspaceCleanupBackgroundRemoval({ @@ -26,7 +33,8 @@ export function startWorkspaceCleanupBackgroundRemoval({ removeCandidates, onProgress, onResult, - onError + onError, + removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS }: WorkspaceCleanupBackgroundRemovalArgs): void { if (candidates.length === 0) { return @@ -35,6 +43,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const count = candidates.length const removedIds: string[] = [] const failures: WorkspaceCleanupFailure[] = [] + const failedCandidates: WorkspaceCleanupCandidate[] = [] let processedCount = 0 const emitProgress = (): void => { @@ -48,13 +57,43 @@ export function startWorkspaceCleanupBackgroundRemoval({ emitProgress() + // Why: keep the store's nested-worktree delete invariant even though progress + // is emitted per row; children must be removed before parent workspaces. + const candidatesInRemovalOrder = [...candidates].sort((a, b) => b.path.length - a.path.length) + void (async () => { - for (const candidate of candidates) { + for (const candidate of candidatesInRemovalOrder) { + if ( + failedCandidates.some((failedCandidate) => + isStrictWorkspaceCleanupDescendant(candidate, failedCandidate) + ) + ) { + failedCandidates.push(candidate) + failures.push({ + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: translate( + 'auto.components.workspace.cleanup.backgroundRemoval.skippedAncestor', + 'Skipped because a nested workspace could not be removed.' + ) + }) + processedCount += 1 + emitProgress() + continue + } try { - const result = await removeCandidates([candidate.worktreeId]) + const result = await withWorkspaceCleanupRemovalTimeout( + removeCandidates([candidate.worktreeId]), + candidate, + removalTimeoutMs + ) removedIds.push(...result.removedIds) failures.push(...result.failures) + if (result.failures.length > 0) { + failedCandidates.push(candidate) + } } catch (error: unknown) { + failedCandidates.push(candidate) failures.push({ worktreeId: candidate.worktreeId, displayName: candidate.displayName, @@ -67,16 +106,19 @@ export function startWorkspaceCleanupBackgroundRemoval({ } const result = { removedIds, failures } - onResult?.(result) + try { + onResult?.(result) + } catch (callbackError) { + console.error('Workspace cleanup result callback failed', callbackError) + } if (result.removedIds.length > 0) { toast.success( translate( 'auto.components.workspace.cleanup.backgroundRemoval.removed', - 'Removed {{value0}} workspace{{value1}}', + 'Removed workspaces: {{value0}}', { - value0: result.removedIds.length, - value1: result.removedIds.length === 1 ? '' : 's' + value0: result.removedIds.length } ) ) @@ -86,14 +128,13 @@ export function startWorkspaceCleanupBackgroundRemoval({ toast.error( translate( 'auto.components.workspace.cleanup.backgroundRemoval.failed', - '{{value0}} workspace{{value1}} could not be removed', + 'Workspaces not removed: {{value0}}', { - value0: result.failures.length, - value1: result.failures.length === 1 ? '' : 's' + value0: result.failures.length } ), { - description: result.failures[0]?.message + description: result.failures.map((failure) => failure.message).join('; ') } ) } @@ -110,3 +151,54 @@ export function startWorkspaceCleanupBackgroundRemoval({ ) }) } + +async function withWorkspaceCleanupRemovalTimeout( + promise: Promise, + candidate: WorkspaceCleanupCandidate, + timeoutMs: number +): Promise { + if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) { + return promise + } + + let timeout: ReturnType | null = null + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject( + new Error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.timedOut', + 'Timed out removing {{value0}}.', + { value0: candidate.displayName } + ) + ) + ) + }, timeoutMs) + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function isStrictWorkspaceCleanupDescendant( + parent: WorkspaceCleanupCandidate, + child: WorkspaceCleanupCandidate +): boolean { + return ( + parent.connectionId === child.connectionId && + isStrictWorkspaceCleanupDescendantPath(parent.path, child.path) + ) +} + +function isStrictWorkspaceCleanupDescendantPath(parentPath: string, childPath: string): boolean { + return ( + normalizeRuntimePathForComparison(parentPath) !== + normalizeRuntimePathForComparison(childPath) && isPathInsideOrEqual(parentPath, childPath) + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts new file mode 100644 index 00000000000..78b98f9b4d0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts @@ -0,0 +1,176 @@ +import { translate } from '@/i18n/i18n' +import type { WorkspaceCleanupBlocker } from '../../../../shared/workspace-cleanup' + +type ContextDetailKind = 'terminal' | 'editor' | 'browser' | 'diff' | 'agent' + +export function getWorkspaceCleanupBlockerLabel(blocker: WorkspaceCleanupBlocker): string { + switch (blocker) { + case 'main-worktree': + return translate( + 'auto.components.workspace.cleanup.candidateRow.mainWorkspaceBlocker', + 'Main workspace' + ) + case 'folder-repo': + return translate( + 'auto.components.workspace.cleanup.candidateRow.folderProjectBlocker', + 'Folder project' + ) + case 'pinned': + return translate('auto.components.workspace.cleanup.candidateRow.pinnedBlocker', 'Pinned') + case 'active-workspace': + return translate( + 'auto.components.workspace.cleanup.candidateRow.activeWorkspaceBlocker', + 'Active workspace' + ) + case 'running-terminal': + return translate( + 'auto.components.workspace.cleanup.candidateRow.runningTerminalBlocker', + 'Running terminal process' + ) + case 'terminal-liveness-unknown': + return translate( + 'auto.components.workspace.cleanup.candidateRow.terminalLivenessUnknownBlocker', + 'Terminal liveness unknown' + ) + case 'dirty-editor-buffer': + return translate( + 'auto.components.workspace.cleanup.candidateRow.dirtyEditorBufferBlocker', + 'Unsaved editor buffer' + ) + case 'volatile-local-context': + return translate( + 'auto.components.workspace.cleanup.candidateRow.volatileLocalContextBlocker', + 'Volatile local context' + ) + case 'recent-visible-context': + return translate( + 'auto.components.workspace.cleanup.candidateRow.recentVisibleContextBlocker', + 'Recently visited tabs' + ) + case 'live-agent': + return translate( + 'auto.components.workspace.cleanup.candidateRow.liveAgentBlocker', + 'Active agent' + ) + case 'ssh-disconnected': + return translate( + 'auto.components.workspace.cleanup.candidateRow.sshDisconnectedBlocker', + 'Remote unavailable' + ) + case 'git-status-error': + return translate( + 'auto.components.workspace.cleanup.candidateRow.gitStatusErrorBlocker', + 'Git status unavailable' + ) + case 'dirty-files': + return translate( + 'auto.components.workspace.cleanup.candidateRow.dirtyFilesBlocker', + 'Changed files' + ) + case 'unpushed-commits': + return getUnpushedCommitsLabel() + case 'unknown-base': + return translate( + 'auto.components.workspace.cleanup.candidateRow.unknownBaseBlocker', + 'Could not verify unpushed commits' + ) + case 'dismissed': + return translate('auto.components.workspace.cleanup.candidateRow.dismissedBlocker', 'Ignored') + } +} + +export function formatWorkspaceCleanupGitStatusLabel(label: string): string { + switch (label) { + case 'Clean': + return translate('auto.components.workspace.cleanup.candidateRow.cleanGit', 'Clean git') + case 'Dirty': + return translate('auto.components.workspace.cleanup.candidateRow.dirtyGit', 'Dirty git') + case 'Unpushed': + return getUnpushedCommitsLabel() + case 'Unknown': + return translate('auto.components.workspace.cleanup.candidateRow.gitUnknown', 'Git unknown') + } + return translate('auto.components.workspace.cleanup.candidateRow.gitUnknown', 'Git unknown') +} + +export function getNoUnpushedCommitsLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.noUnpushedCommits', + 'No unpushed commits' + ) +} + +export function getUnpushedCommitsLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.unpushedCommits', + 'Unpushed commits' + ) +} + +export function formatUnpushedCommitCount(count: number): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.unpushedCommitsCount', + 'Unpushed commits: {{value0}}', + { value0: count } + ) +} + +export function getUncommittedChangesLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.uncommittedChanges', + 'Uncommitted changes' + ) +} + +export function getGitStatusUnknownLabel(): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.gitStatusUnknown', + 'Git status unknown' + ) +} + +export function formatWorkspaceCleanupContextDetail( + kind: ContextDetailKind, + count: number +): string { + switch (kind) { + case 'terminal': + return translate( + 'auto.components.workspace.cleanup.candidateRow.terminalTabsCount', + 'Terminal tabs: {{value0}}', + { value0: count } + ) + case 'editor': + return translate( + 'auto.components.workspace.cleanup.candidateRow.editorTabsCount', + 'Editor tabs: {{value0}}', + { value0: count } + ) + case 'browser': + return translate( + 'auto.components.workspace.cleanup.candidateRow.browserTabsCount', + 'Browser tabs: {{value0}}', + { value0: count } + ) + case 'diff': + return translate( + 'auto.components.workspace.cleanup.candidateRow.diffNotesCount', + 'Diff notes: {{value0}}', + { value0: count } + ) + case 'agent': + return translate( + 'auto.components.workspace.cleanup.candidateRow.completedAgentsCount', + 'Completed agents: {{value0}}', + { value0: count } + ) + } +} + +export function formatWorkspaceCleanupContextCount(count: number): string { + return translate( + 'auto.components.workspace.cleanup.candidateRow.contextCount', + 'Context: {{value0}}', + { value0: count } + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts index 41aa976eebc..3cd5309ac54 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.test.ts @@ -19,7 +19,7 @@ describe('workspace cleanup candidate row data', () => { expect(shouldShowGitMetadataChip(unknownBase)).toBe(false) }) - it('keeps the git metadata chip for ordinary clean and dirty rows', () => { + it('keeps the git metadata chip for ordinary clean rows', () => { expect( shouldShowGitMetadataChip( makeCandidate({ @@ -27,12 +27,33 @@ describe('workspace cleanup candidate row data', () => { }) ) ).toBe(true) + }) + + it('suppresses the git metadata chip when the status pill already names git risk', () => { expect( shouldShowGitMetadataChip( makeCandidate({ + blockers: ['unpushed-commits'], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + expect( + shouldShowGitMetadataChip( + makeCandidate({ + tier: 'review', + blockers: [], + git: { clean: true, upstreamAhead: 2, upstreamBehind: 0, checkedAt: 1 } + }) + ) + ).toBe(false) + expect( + shouldShowGitMetadataChip( + makeCandidate({ + blockers: ['dirty-files'], git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 } }) ) - ).toBe(true) + ).toBe(false) }) }) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts index b0ed7a35c83..9e819d90017 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -1,37 +1,26 @@ import { translate } from '@/i18n/i18n' -import type { - WorkspaceCleanupBlocker, - WorkspaceCleanupCandidate -} from '../../../../shared/workspace-cleanup' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' import { getWorkspaceCleanupGitLabel, hasWorkspaceCleanupLocalContext, type WorkspaceCleanupReviewInfo } from './workspace-cleanup-presentation' +import { + formatUnpushedCommitCount, + formatWorkspaceCleanupContextCount, + formatWorkspaceCleanupContextDetail, + formatWorkspaceCleanupGitStatusLabel, + getGitStatusUnknownLabel, + getNoUnpushedCommitsLabel, + getUncommittedChangesLabel, + getUnpushedCommitsLabel, + getWorkspaceCleanupBlockerLabel +} from './workspace-cleanup-candidate-labels' export type StatusPillTone = 'neutral' | 'ready' | 'review' | 'destructive' -const BLOCKER_LABELS: Record = { - 'main-worktree': 'Main workspace', - 'folder-repo': 'Folder project', - pinned: 'Pinned', - 'active-workspace': 'Active workspace', - 'running-terminal': 'Running terminal process', - 'terminal-liveness-unknown': 'Terminal liveness unknown', - 'dirty-editor-buffer': 'Unsaved editor buffer', - 'volatile-local-context': 'Volatile local context', - 'recent-visible-context': 'Recently visited tabs', - 'live-agent': 'Active agent', - 'ssh-disconnected': 'Remote unavailable', - 'git-status-error': 'Git status unavailable', - 'dirty-files': 'Changed files', - 'unpushed-commits': 'Unpushed commits', - 'unknown-base': 'Could not verify unpushed commits', - dismissed: 'Ignored' -} - export function getWorkspaceCleanupBlockerLabels(candidate: WorkspaceCleanupCandidate): string[] { - return candidate.blockers.map((blocker) => BLOCKER_LABELS[blocker]) + return candidate.blockers.map((blocker) => getWorkspaceCleanupBlockerLabel(blocker)) } export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { @@ -62,7 +51,7 @@ export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { } } if (candidate.blockers.length > 0) { - return { label: BLOCKER_LABELS[candidate.blockers[0]], tone: 'neutral' } + return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' } } if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { return { @@ -101,18 +90,7 @@ export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): { } export function formatGitStatus(candidate: WorkspaceCleanupCandidate): string { - const label = getWorkspaceCleanupGitLabel(candidate) - switch (label) { - case 'Clean': - return 'Clean git' - case 'Dirty': - return 'Dirty git' - case 'Unpushed': - return 'Unpushed commits' - case 'Unknown': - return 'Git unknown' - } - return 'Git unknown' + return formatWorkspaceCleanupGitStatusLabel(getWorkspaceCleanupGitLabel(candidate)) } export function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): string[] { @@ -120,10 +98,8 @@ export function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): if (candidate.git.upstreamAhead !== null) { details.push( candidate.git.upstreamAhead === 0 - ? 'No unpushed commits' - : `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` + ? getNoUnpushedCommitsLabel() + : formatUnpushedCommitCount(candidate.git.upstreamAhead) ) } return details @@ -133,37 +109,25 @@ export function formatContextDetails(candidate: WorkspaceCleanupCandidate): stri const parts: string[] = [] if (candidate.localContext.terminalTabCount > 0) { parts.push( - `${candidate.localContext.terminalTabCount} terminal tab${ - candidate.localContext.terminalTabCount === 1 ? '' : 's' - }` + formatWorkspaceCleanupContextDetail('terminal', candidate.localContext.terminalTabCount) ) } if (candidate.localContext.cleanEditorTabCount > 0) { parts.push( - `${candidate.localContext.cleanEditorTabCount} editor tab${ - candidate.localContext.cleanEditorTabCount === 1 ? '' : 's' - }` + formatWorkspaceCleanupContextDetail('editor', candidate.localContext.cleanEditorTabCount) ) } if (candidate.localContext.browserTabCount > 0) { parts.push( - `${candidate.localContext.browserTabCount} browser tab${ - candidate.localContext.browserTabCount === 1 ? '' : 's' - }` + formatWorkspaceCleanupContextDetail('browser', candidate.localContext.browserTabCount) ) } if (candidate.localContext.diffCommentCount > 0) { - parts.push( - `${candidate.localContext.diffCommentCount} diff note${ - candidate.localContext.diffCommentCount === 1 ? '' : 's' - }` - ) + parts.push(formatWorkspaceCleanupContextDetail('diff', candidate.localContext.diffCommentCount)) } if (candidate.localContext.retainedDoneAgentCount > 0) { parts.push( - `${candidate.localContext.retainedDoneAgentCount} completed agent${ - candidate.localContext.retainedDoneAgentCount === 1 ? '' : 's' - }` + formatWorkspaceCleanupContextDetail('agent', candidate.localContext.retainedDoneAgentCount) ) } return parts.length > 0 ? parts.join(', ') : null @@ -178,32 +142,43 @@ export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | } if (candidate.blockers.includes('unpushed-commits')) { if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` + return formatUnpushedCommitCount(candidate.git.upstreamAhead) } - return 'Unpushed commits' + return getUnpushedCommitsLabel() } if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) { - return `${candidate.git.upstreamAhead} unpushed commit${ - candidate.git.upstreamAhead === 1 ? '' : 's' - }` + return formatUnpushedCommitCount(candidate.git.upstreamAhead) } if (candidate.git.clean === false) { - return 'Uncommitted changes' + return getUncommittedChangesLabel() } if (candidate.git.clean == null) { - return 'Git status unknown' + return getGitStatusUnknownLabel() } return null } export function shouldShowGitMetadataChip(candidate: WorkspaceCleanupCandidate): boolean { return ( - !candidate.blockers.includes('unknown-base') && !candidate.blockers.includes('git-status-error') + !candidate.blockers.includes('unknown-base') && + !candidate.blockers.includes('git-status-error') && + !hasGitStatusPill(candidate) ) } +function hasGitStatusPill(candidate: WorkspaceCleanupCandidate): boolean { + if ( + candidate.blockers.includes('dirty-files') || + candidate.blockers.includes('unpushed-commits') + ) { + return true + } + if (candidate.blockers.length > 0 || candidate.tier === 'ready') { + return false + } + return (candidate.git.upstreamAhead ?? 0) > 0 || candidate.git.clean === false +} + export function getReviewPillTone(reviewInfo: WorkspaceCleanupReviewInfo): StatusPillTone { if (reviewInfo.state === 'open' || reviewInfo.state === 'draft') { return 'review' @@ -215,7 +190,7 @@ export function getContextPillLabel(candidate: WorkspaceCleanupCandidate): strin if (!hasWorkspaceCleanupLocalContext(candidate)) { return null } - return `${getContextCount(candidate)} context` + return formatWorkspaceCleanupContextCount(getContextCount(candidate)) } export function getContextCount(candidate: WorkspaceCleanupCandidate): number { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx new file mode 100644 index 00000000000..b65e495a7ea --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment happy-dom +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +let root: Root | null = null +let container: HTMLDivElement | null = null + +describe('CandidateRow', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('hides selection and remove controls while the workspace is already deleting', () => { + const candidate = makeCandidate() + + act(() => { + root?.render( + + ) + }) + + expect(container?.querySelector(`[aria-label="Select ${candidate.displayName}"]`)).toBeNull() + expect(container?.querySelector(`[aria-label="Remove ${candidate.displayName}"]`)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index 6cae3a1c3ce..04fb02d28c2 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -46,6 +46,7 @@ type CandidateRowProps = { failure?: string last: boolean lastActivityLabel: string + removing?: boolean reviewInfo: WorkspaceCleanupReviewInfo selected: boolean onIgnore: (candidate: WorkspaceCleanupCandidate) => void @@ -75,7 +76,7 @@ function MetadataIconChip({ 'border-border bg-background text-muted-foreground', tone === 'ready' && 'border-[color:color-mix(in_srgb,var(--git-decoration-added)_45%,transparent)] bg-[color:color-mix(in_srgb,var(--git-decoration-added)_10%,transparent)] text-[var(--git-decoration-added)]', - tone === 'review' && 'text-[var(--git-decoration-modified)]', + tone === 'review' && 'bg-muted text-foreground', tone === 'destructive' && 'border-destructive/30 text-destructive' )} aria-label={label} @@ -97,6 +98,7 @@ export function CandidateRow({ failure, last, lastActivityLabel, + removing = false, reviewInfo, selected, onIgnore, @@ -105,7 +107,7 @@ export function CandidateRow({ onToggleSelected, onView }: CandidateRowProps): React.JSX.Element { - const selectable = canQueueWorkspaceCleanupCandidate(candidate) + const selectable = canQueueWorkspaceCleanupCandidate(candidate) && !removing const ignored = candidate.blockers.includes('dismissed') const blockers = getWorkspaceCleanupBlockerLabels(candidate) const contextDetails = formatContextDetails(candidate) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts new file mode 100644 index 00000000000..5b5f000c19c --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' +import { filterWorkspaceCleanupRemovalCandidates } from './workspace-cleanup-removal-candidates' + +describe('workspace cleanup removal candidates', () => { + it('excludes workspaces already being deleted', () => { + const deleting = makeCandidate({ worktreeId: 'repo-1::/repo/deleting' }) + const ready = makeCandidate({ worktreeId: 'repo-1::/repo/ready' }) + + expect( + filterWorkspaceCleanupRemovalCandidates([deleting, ready], { + [deleting.worktreeId]: { isDeleting: true } + }) + ).toEqual([ready]) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts new file mode 100644 index 00000000000..5c97d780f31 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-candidates.ts @@ -0,0 +1,14 @@ +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +type DeleteState = { + isDeleting?: boolean +} + +export function filterWorkspaceCleanupRemovalCandidates( + candidates: readonly WorkspaceCleanupCandidate[], + deleteStateByWorktreeId: Record +): WorkspaceCleanupCandidate[] { + return candidates.filter( + (candidate) => deleteStateByWorktreeId[candidate.worktreeId]?.isDeleting !== true + ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx index 344591c5c48..1e5786fda49 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-status-pill.tsx @@ -14,8 +14,9 @@ export function StatusPill({ className={cn( 'inline-flex h-5 items-center rounded-full border px-2 text-[11px] font-medium', tone === 'neutral' && 'border-border bg-background text-muted-foreground', - tone === 'ready' && 'border-border text-[var(--git-decoration-added)]', - tone === 'review' && 'border-border text-[var(--git-decoration-modified)]', + tone === 'ready' && + 'border-status-success-border bg-status-success-background text-status-success', + tone === 'review' && 'border-border bg-muted text-foreground', tone === 'destructive' && 'border-destructive/30 text-destructive' )} > diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 570c20c6c54..9d3056a7ae3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2339,7 +2339,62 @@ "gitFilter": "Git", "contextFilter": "Context", "sortBy": "Sort by", - "sortDirection": "Direction" + "sortDirection": "Direction", + "e50c4da067": "Deleting", + "1d3503357d": "You can close this and come back while deletion continues.", + "4c2990886e": "{{value0}}/{{value1}} deleted", + "86ba852118": "{{value0}}, {{value1}} failed", + "7b7bde5181": "Checked workspaces so far: {{value0}}", + "deletingCount": "Deleting workspaces: {{value0}}", + "deleteCount": "Delete workspaces: {{value0}}?", + "selectedForDeletionCount": "Selected for deletion: {{value0}}", + "deleteButtonCount": "Delete {{value0}}", + "archivedStatus": "Archived", + "readyStatus": "Ready" + }, + "backgroundRemoval": { + "removed": "Removed workspaces: {{value0}}", + "failed": "Workspaces not removed: {{value0}}", + "error": "Workspace cleanup failed", + "skippedAncestor": "Skipped because a nested workspace could not be removed.", + "timedOut": "Timed out removing {{value0}}." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "Commits", + "contextLabel": "Context", + "flagsLabel": "Flags", + "collapseDetails": "Collapse details", + "expandDetails": "Expand details", + "cleanGit": "Clean git", + "dirtyGit": "Dirty git", + "unpushedCommits": "Unpushed commits", + "gitUnknown": "Git unknown", + "gitStatusUnknown": "Git status unknown", + "noUnpushedCommits": "No unpushed commits", + "unpushedCommitsCount": "Unpushed commits: {{value0}}", + "uncommittedChanges": "Uncommitted changes", + "terminalTabsCount": "Terminal tabs: {{value0}}", + "editorTabsCount": "Editor tabs: {{value0}}", + "browserTabsCount": "Browser tabs: {{value0}}", + "diffNotesCount": "Diff notes: {{value0}}", + "completedAgentsCount": "Completed agents: {{value0}}", + "contextCount": "Context: {{value0}}", + "mainWorkspaceBlocker": "Main workspace", + "folderProjectBlocker": "Folder project", + "pinnedBlocker": "Pinned", + "activeWorkspaceBlocker": "Active workspace", + "runningTerminalBlocker": "Running terminal process", + "terminalLivenessUnknownBlocker": "Terminal liveness unknown", + "dirtyEditorBufferBlocker": "Unsaved editor buffer", + "volatileLocalContextBlocker": "Volatile local context", + "recentVisibleContextBlocker": "Recently visited tabs", + "liveAgentBlocker": "Active agent", + "sshDisconnectedBlocker": "Remote unavailable", + "gitStatusErrorBlocker": "Git status unavailable", + "dirtyFilesBlocker": "Changed files", + "unknownBaseBlocker": "Could not verify unpushed commits", + "dismissedBlocker": "Ignored" } } }, @@ -4027,7 +4082,8 @@ "runtimeHostProject": "Project on Orca server", "automationCreated": "Created by automation", "branchIdentity": "Branch", - "branchFolderPathIdentity": "Branch or folder path" + "branchFolderPathIdentity": "Branch or folder path", + "ef18787206": "Queued for deletion" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 2543c3ce6d3..70ce1719106 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2339,7 +2339,62 @@ "gitFilter": "Git", "contextFilter": "Contexto", "sortBy": "Ordenar por", - "sortDirection": "Dirección" + "sortDirection": "Dirección", + "e50c4da067": "Eliminando", + "1d3503357d": "Puedes cerrar esto y volver mientras la eliminación continúa.", + "4c2990886e": "{{value0}}/{{value1}} eliminados", + "86ba852118": "{{value0}}, {{value1}} fallidos", + "7b7bde5181": "Espacios de trabajo comprobados hasta ahora: {{value0}}", + "deletingCount": "Eliminando espacios de trabajo: {{value0}}", + "deleteCount": "¿Eliminar espacios de trabajo: {{value0}}?", + "selectedForDeletionCount": "Seleccionados para eliminar: {{value0}}", + "deleteButtonCount": "Eliminar {{value0}}", + "archivedStatus": "Archivado", + "readyStatus": "Listo" + }, + "backgroundRemoval": { + "removed": "Espacios de trabajo eliminados: {{value0}}", + "failed": "Espacios de trabajo no eliminados: {{value0}}", + "error": "Error en la limpieza del espacio de trabajo", + "skippedAncestor": "Se omitió porque no se pudo eliminar un espacio de trabajo anidado.", + "timedOut": "Se agotó el tiempo al eliminar {{value0}}." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "Confirmaciones", + "contextLabel": "Contexto", + "flagsLabel": "Indicadores", + "collapseDetails": "Contraer detalles", + "expandDetails": "Expandir detalles", + "cleanGit": "Git limpio", + "dirtyGit": "Git con cambios", + "unpushedCommits": "Confirmaciones sin enviar", + "gitUnknown": "Git desconocido", + "gitStatusUnknown": "Estado de Git desconocido", + "noUnpushedCommits": "Sin confirmaciones sin enviar", + "unpushedCommitsCount": "Confirmaciones sin enviar: {{value0}}", + "uncommittedChanges": "Cambios sin confirmar", + "terminalTabsCount": "Pestañas de terminal: {{value0}}", + "editorTabsCount": "Pestañas de editor: {{value0}}", + "browserTabsCount": "Pestañas de navegador: {{value0}}", + "diffNotesCount": "Notas de diff: {{value0}}", + "completedAgentsCount": "Agentes completados: {{value0}}", + "contextCount": "Contexto: {{value0}}", + "mainWorkspaceBlocker": "Espacio de trabajo principal", + "folderProjectBlocker": "Proyecto de carpeta", + "pinnedBlocker": "Fijado", + "activeWorkspaceBlocker": "Espacio de trabajo activo", + "runningTerminalBlocker": "Proceso de terminal en ejecución", + "terminalLivenessUnknownBlocker": "Estado del terminal desconocido", + "dirtyEditorBufferBlocker": "Búfer del editor sin guardar", + "volatileLocalContextBlocker": "Contexto local volátil", + "recentVisibleContextBlocker": "Pestañas visitadas recientemente", + "liveAgentBlocker": "Agente activo", + "sshDisconnectedBlocker": "Remoto no disponible", + "gitStatusErrorBlocker": "Estado de Git no disponible", + "dirtyFilesBlocker": "Archivos modificados", + "unknownBaseBlocker": "No se pudieron verificar confirmaciones sin enviar", + "dismissedBlocker": "Ignorado" } } }, @@ -4004,7 +4059,8 @@ "runtimeHostProject": "Proyecto en servidor de Orca", "automationCreated": "Creado por automatización", "branchIdentity": "Rama", - "branchFolderPathIdentity": "Rama o ruta de carpeta" + "branchFolderPathIdentity": "Rama o ruta de carpeta", + "ef18787206": "En cola para eliminación" }, "WorktreeCardAgents": { "1b0a156717": "Agents" diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9727ae4adc4..a8a7ea24879 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2339,7 +2339,62 @@ "gitFilter": "Git", "contextFilter": "コンテクスト", "sortBy": "並べ替え", - "sortDirection": "方向" + "sortDirection": "方向", + "e50c4da067": "削除中", + "1d3503357d": "削除の続行中にこれを閉じて後で戻れます。", + "4c2990886e": "{{value0}}/{{value1}} 件を削除済み", + "86ba852118": "{{value0}}、{{value1}} 件が失敗", + "7b7bde5181": "これまでに確認したワークスペース: {{value0}}", + "deletingCount": "削除中のワークスペース: {{value0}}", + "deleteCount": "ワークスペースを削除しますか: {{value0}}?", + "selectedForDeletionCount": "削除対象として選択済み: {{value0}}", + "deleteButtonCount": "{{value0}} 件を削除", + "archivedStatus": "アーカイブ済み", + "readyStatus": "準備完了" + }, + "backgroundRemoval": { + "removed": "削除済みワークスペース: {{value0}}", + "failed": "削除できなかったワークスペース: {{value0}}", + "error": "ワークスペースのクリーンアップに失敗しました", + "skippedAncestor": "ネストされたワークスペースを削除できなかったためスキップしました。", + "timedOut": "{{value0}} の削除がタイムアウトしました。" + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "コミット", + "contextLabel": "コンテキスト", + "flagsLabel": "フラグ", + "collapseDetails": "詳細を折りたたむ", + "expandDetails": "詳細を展開", + "cleanGit": "Git はクリーン", + "dirtyGit": "Git に変更あり", + "unpushedCommits": "未プッシュのコミット", + "gitUnknown": "Git 不明", + "gitStatusUnknown": "Git 状態不明", + "noUnpushedCommits": "未プッシュのコミットなし", + "unpushedCommitsCount": "未プッシュのコミット: {{value0}}", + "uncommittedChanges": "未コミットの変更", + "terminalTabsCount": "ターミナルタブ: {{value0}}", + "editorTabsCount": "エディタタブ: {{value0}}", + "browserTabsCount": "ブラウザタブ: {{value0}}", + "diffNotesCount": "差分メモ: {{value0}}", + "completedAgentsCount": "完了済みエージェント: {{value0}}", + "contextCount": "コンテキスト: {{value0}}", + "mainWorkspaceBlocker": "メインワークスペース", + "folderProjectBlocker": "フォルダープロジェクト", + "pinnedBlocker": "ピン留め済み", + "activeWorkspaceBlocker": "アクティブなワークスペース", + "runningTerminalBlocker": "実行中のターミナルプロセス", + "terminalLivenessUnknownBlocker": "ターミナルの状態不明", + "dirtyEditorBufferBlocker": "未保存のエディタバッファ", + "volatileLocalContextBlocker": "揮発性のローカルコンテキスト", + "recentVisibleContextBlocker": "最近表示したタブ", + "liveAgentBlocker": "アクティブなエージェント", + "sshDisconnectedBlocker": "リモート利用不可", + "gitStatusErrorBlocker": "Git 状態を利用できません", + "dirtyFilesBlocker": "変更済みファイル", + "unknownBaseBlocker": "未プッシュのコミットを確認できませんでした", + "dismissedBlocker": "無視済み" } } }, @@ -3985,7 +4040,8 @@ "runtimeHostProject": "Orca サーバー上のプロジェクト", "automationCreated": "自動化により作成", "branchIdentity": "ブランチ", - "branchFolderPathIdentity": "ブランチまたはフォルダパス" + "branchFolderPathIdentity": "ブランチまたはフォルダパス", + "ef18787206": "削除待ち" }, "WorktreeCardAgents": { "1b0a156717": "エージェント" diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index bff86bf9af6..e4737a04266 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2339,7 +2339,62 @@ "gitFilter": "Git", "contextFilter": "문맥", "sortBy": "정렬 기준", - "sortDirection": "방향" + "sortDirection": "방향", + "e50c4da067": "삭제 중", + "1d3503357d": "삭제가 계속되는 동안 이 창을 닫고 나중에 돌아올 수 있습니다.", + "4c2990886e": "{{value0}}/{{value1}}개 삭제됨", + "86ba852118": "{{value0}}, {{value1}}개 실패", + "7b7bde5181": "지금까지 확인한 워크스페이스: {{value0}}", + "deletingCount": "삭제 중인 워크스페이스: {{value0}}", + "deleteCount": "워크스페이스 삭제: {{value0}}?", + "selectedForDeletionCount": "삭제 대상으로 선택됨: {{value0}}", + "deleteButtonCount": "{{value0}}개 삭제", + "archivedStatus": "보관됨", + "readyStatus": "준비됨" + }, + "backgroundRemoval": { + "removed": "삭제된 워크스페이스: {{value0}}", + "failed": "삭제되지 않은 워크스페이스: {{value0}}", + "error": "워크스페이스 정리 실패", + "skippedAncestor": "중첩된 워크스페이스를 제거할 수 없어 건너뛰었습니다.", + "timedOut": "{{value0}} 삭제 시간이 초과되었습니다." + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "커밋", + "contextLabel": "문맥", + "flagsLabel": "플래그", + "collapseDetails": "세부 정보 접기", + "expandDetails": "세부 정보 펼치기", + "cleanGit": "Git 깨끗함", + "dirtyGit": "Git 변경 있음", + "unpushedCommits": "푸시되지 않은 커밋", + "gitUnknown": "Git 알 수 없음", + "gitStatusUnknown": "Git 상태 알 수 없음", + "noUnpushedCommits": "푸시되지 않은 커밋 없음", + "unpushedCommitsCount": "푸시되지 않은 커밋: {{value0}}", + "uncommittedChanges": "커밋되지 않은 변경 사항", + "terminalTabsCount": "터미널 탭: {{value0}}", + "editorTabsCount": "편집기 탭: {{value0}}", + "browserTabsCount": "브라우저 탭: {{value0}}", + "diffNotesCount": "Diff 메모: {{value0}}", + "completedAgentsCount": "완료된 에이전트: {{value0}}", + "contextCount": "문맥: {{value0}}", + "mainWorkspaceBlocker": "기본 워크스페이스", + "folderProjectBlocker": "폴더 프로젝트", + "pinnedBlocker": "고정됨", + "activeWorkspaceBlocker": "활성 워크스페이스", + "runningTerminalBlocker": "실행 중인 터미널 프로세스", + "terminalLivenessUnknownBlocker": "터미널 상태 알 수 없음", + "dirtyEditorBufferBlocker": "저장되지 않은 편집기 버퍼", + "volatileLocalContextBlocker": "휘발성 로컬 문맥", + "recentVisibleContextBlocker": "최근 방문한 탭", + "liveAgentBlocker": "활성 에이전트", + "sshDisconnectedBlocker": "원격을 사용할 수 없음", + "gitStatusErrorBlocker": "Git 상태를 사용할 수 없음", + "dirtyFilesBlocker": "변경된 파일", + "unknownBaseBlocker": "푸시되지 않은 커밋을 확인할 수 없음", + "dismissedBlocker": "무시됨" } } }, @@ -3985,7 +4040,8 @@ "runtimeHostProject": "Orca 서버의 프로젝트", "automationCreated": "자동화로 생성됨", "branchIdentity": "브랜치", - "branchFolderPathIdentity": "브랜치 또는 폴더 경로" + "branchFolderPathIdentity": "브랜치 또는 폴더 경로", + "ef18787206": "삭제 대기 중" }, "WorktreeCardAgents": { "1b0a156717": "에이전트" diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a0b21163a78..09f538b6f4f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2339,7 +2339,62 @@ "gitFilter": "Git", "contextFilter": "上下文", "sortBy": "排序依据", - "sortDirection": "方向" + "sortDirection": "方向", + "e50c4da067": "正在删除", + "1d3503357d": "删除继续期间,你可以关闭此窗口并稍后回来。", + "4c2990886e": "已删除 {{value0}}/{{value1}}", + "86ba852118": "{{value0}},{{value1}} 个失败", + "7b7bde5181": "到目前为止已检查工作区:{{value0}}", + "deletingCount": "正在删除工作区:{{value0}}", + "deleteCount": "删除工作区:{{value0}}?", + "selectedForDeletionCount": "已选择删除:{{value0}}", + "deleteButtonCount": "删除 {{value0}}", + "archivedStatus": "已归档", + "readyStatus": "就绪" + }, + "backgroundRemoval": { + "removed": "已删除工作区:{{value0}}", + "failed": "未删除工作区:{{value0}}", + "error": "工作区清理失败", + "skippedAncestor": "由于无法删除嵌套工作区,已跳过。", + "timedOut": "删除 {{value0}} 超时。" + }, + "candidateRow": { + "gitLabel": "Git", + "commitsLabel": "提交", + "contextLabel": "上下文", + "flagsLabel": "标记", + "collapseDetails": "收起详情", + "expandDetails": "展开详情", + "cleanGit": "Git 干净", + "dirtyGit": "Git 有更改", + "unpushedCommits": "未推送提交", + "gitUnknown": "Git 未知", + "gitStatusUnknown": "Git 状态未知", + "noUnpushedCommits": "没有未推送提交", + "unpushedCommitsCount": "未推送提交:{{value0}}", + "uncommittedChanges": "未提交的更改", + "terminalTabsCount": "终端标签页:{{value0}}", + "editorTabsCount": "编辑器标签页:{{value0}}", + "browserTabsCount": "浏览器标签页:{{value0}}", + "diffNotesCount": "Diff 备注:{{value0}}", + "completedAgentsCount": "已完成智能体:{{value0}}", + "contextCount": "上下文:{{value0}}", + "mainWorkspaceBlocker": "主工作区", + "folderProjectBlocker": "文件夹项目", + "pinnedBlocker": "已固定", + "activeWorkspaceBlocker": "活动工作区", + "runningTerminalBlocker": "正在运行的终端进程", + "terminalLivenessUnknownBlocker": "终端状态未知", + "dirtyEditorBufferBlocker": "未保存的编辑器缓冲区", + "volatileLocalContextBlocker": "易变本地上下文", + "recentVisibleContextBlocker": "最近访问的标签页", + "liveAgentBlocker": "活动智能体", + "sshDisconnectedBlocker": "远程不可用", + "gitStatusErrorBlocker": "Git 状态不可用", + "dirtyFilesBlocker": "已更改文件", + "unknownBaseBlocker": "无法验证未推送提交", + "dismissedBlocker": "已忽略" } } }, @@ -3985,7 +4040,8 @@ "runtimeHostProject": "Orca 服务器上的项目", "automationCreated": "由自动化创建", "branchIdentity": "分支", - "branchFolderPathIdentity": "分支或文件夹路径" + "branchFolderPathIdentity": "分支或文件夹路径", + "ef18787206": "已排队删除" }, "WorktreeCardAgents": { "1b0a156717": "智能体" diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 6b489c2dab9..e8f38d53cfe 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -216,6 +216,37 @@ describe('removeWorktree cascade', () => { }) }) + it('can suppress preserved branch warning toasts for batched cleanup removal', async () => { + const store = createTestStore() + const worktreeId = 'repo1::/path/wt1' + mockApi.worktrees.remove.mockResolvedValueOnce({ + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + + seedStore(store, { + worktreesByRepo: { + repo1: [ + makeWorktree({ + id: worktreeId, + repoId: 'repo1', + path: '/path/wt1', + displayName: 'Review cleanup' + }) + ] + } + }) + + const result = await store + .getState() + .removeWorktree(worktreeId, false, { suppressPreservedBranchToast: true }) + + expect(result).toEqual({ + ok: true, + preservedBranch: { branchName: 'feature/test', head: 'def456' } + }) + expect(toast.warning).not.toHaveBeenCalled() + }) + it('sets delete state with dirty/untracked error and canForceDelete=true on failure', async () => { const store = createTestStore() const worktreeId = 'repo1::/path/wt1' @@ -289,6 +320,25 @@ describe('removeWorktree cascade', () => { }) }) + it('keeps active deletion state when cleanup queues stale rows', () => { + const store = createTestStore() + const active = 'repo1::/path/deleting' + const queued = 'repo1::/path/queued' + + seedStore(store, { + deleteStateByWorktreeId: { + [active]: { isDeleting: true, phase: 'deleting', error: null, canForceDelete: false } + } + }) + + store.getState().markWorktreesQueuedForDeletion([active, queued]) + + expect(store.getState().deleteStateByWorktreeId).toMatchObject({ + [active]: { isDeleting: true, phase: 'deleting', error: null, canForceDelete: false }, + [queued]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false } + }) + }) + it('offers force delete for Electron-wrapped local dirty preflight errors', async () => { const store = createTestStore() const worktreeId = 'repo1::/workspace/feature-wt' diff --git a/src/renderer/src/store/slices/workspace-cleanup.test.ts b/src/renderer/src/store/slices/workspace-cleanup.test.ts index 3a5199e2adc..bccb1b66bcc 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.test.ts @@ -289,6 +289,281 @@ describe('workspace cleanup viewed rows', () => { expect(getForegroundProcess).toHaveBeenCalledTimes(1) }) + it('keeps append progress rows when terminal enrichment resolves out of order', async () => { + const pending = deferred() + const terminalProbe = 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().mockReturnValue(terminalProbe.promise) + 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(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 2, + totalWorktreeCount: 2, + candidates: [laterCandidate], + errors: [], + candidateMode: 'append' + }) + await Promise.resolve() + expect(store.getState().workspaceCleanupProgress).toBeNull() + + terminalProbe.resolve(false) + await vi.waitFor(() => { + expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2) + }) + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/terminal', 'repo1::/tmp/later']) + + pending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, laterCandidate], + errors: [] + }) + await scanPromise + }) + + it('publishes the final scan result without waiting for queued progress', async () => { + const pending = deferred() + const terminalProbe = deferred() + let scanSettled = false + let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((_args, progressCallback) => { + onProgress = progressCallback + return pending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + 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() + .finally(() => { + scanSettled = true + }) + onProgress?.({ + scanId: 'scan-1', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + + pending.resolve({ + scannedAt: NOW, + candidates: [finalCandidate], + errors: [] + }) + await scanPromise + expect(scanSettled).toBe(true) + + terminalProbe.resolve(false) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/final']) + expect(store.getState().workspaceCleanupProgress).toMatchObject({ + scannedWorktreeCount: 1, + totalWorktreeCount: 1 + }) + }) + + it('ignores stale progress without replacing the active scan progress queue', async () => { + const firstPending = deferred() + const secondPending = deferred() + const terminalProbe = deferred() + const progressCallbacks: ((progress: WorkspaceCleanupScanProgress) => void)[] = [] + let secondScanSettled = false + const firstCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/first' }) + const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' }) + const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' }) + const scan = vi.fn((args, progressCallback) => { + progressCallbacks.push(progressCallback) + return args?.skipGitWorktreeIds?.includes('second') + ? secondPending.promise + : firstPending.promise + }) + installWorkspaceCleanupApi(scan) + const hasChildProcesses = vi.fn().mockReturnValue(terminalProbe.promise) + 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 firstScan = store.getState().scanWorkspaceCleanup({ skipGitWorktreeIds: ['first'] }) + const secondScan = store + .getState() + .scanWorkspaceCleanup({ skipGitWorktreeIds: ['second'] }) + .finally(() => { + secondScanSettled = true + }) + + progressCallbacks[1]?.({ + scanId: 'scan-2', + scannedAt: NOW, + scannedWorktreeCount: 1, + totalWorktreeCount: 2, + candidates: [terminalCandidate], + errors: [], + candidateMode: 'append' + }) + await vi.waitFor(() => { + expect(hasChildProcesses).toHaveBeenCalledTimes(1) + }) + progressCallbacks[0]?.({ + scanId: 'scan-1', + scannedAt: NOW - 1, + scannedWorktreeCount: 1, + totalWorktreeCount: 1, + candidates: [firstCandidate], + errors: [], + candidateMode: 'append' + }) + + secondPending.resolve({ + scannedAt: NOW, + candidates: [terminalCandidate, finalCandidate], + errors: [] + }) + await Promise.resolve() + expect(secondScanSettled).toBe(false) + + terminalProbe.resolve(false) + await secondScan + firstPending.resolve({ scannedAt: NOW - 1, candidates: [firstCandidate], errors: [] }) + await firstScan + + expect( + store.getState().workspaceCleanupScan?.candidates.map((candidate) => candidate.worktreeId) + ).toEqual(['repo1::/tmp/terminal', 'repo1::/tmp/final']) + }) + + it('does not let an in-flight broad scan revive removed cleanup rows', async () => { + const firstBroadScan = deferred() + const secondBroadScan = deferred() + const candidate = makeCandidate() + const refreshedCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/refreshed' }) + const broadScans = [firstBroadScan, secondBroadScan] + const scan = vi.fn((args?: { worktreeId?: string }) => { + if (args?.worktreeId) { + return Promise.resolve({ scannedAt: NOW, candidates: [candidate], errors: [] }) + } + const nextBroadScan = broadScans.shift() + if (!nextBroadScan) { + throw new Error('unexpected broad scan') + } + return nextBroadScan.promise + }) + installWorkspaceCleanupApi(scan) + const removeWorktree = vi.fn().mockResolvedValue({ ok: true }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ + workspaceCleanupScan: { scannedAt: NOW - 1, candidates: [candidate], errors: [] } + } as Partial) + + const pendingRefresh = store.getState().scanWorkspaceCleanup() + await store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId]) + const replacementRefresh = store.getState().scanWorkspaceCleanup() + + expect(scan).toHaveBeenCalledTimes(3) + + secondBroadScan.resolve({ scannedAt: NOW, candidates: [refreshedCandidate], errors: [] }) + await expect(replacementRefresh).resolves.toMatchObject({ + candidates: [refreshedCandidate] + }) + + firstBroadScan.resolve({ scannedAt: NOW - 1, candidates: [candidate], errors: [] }) + await pendingRefresh + + expect(store.getState().workspaceCleanupLoading).toBe(false) + expect(store.getState().workspaceCleanupScan?.candidates).toEqual([refreshedCandidate]) + }) + 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 502cead194a..f25c6735554 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -78,6 +78,11 @@ let inFlightWorkspaceCleanupScan: { promise: Promise } | null = null let latestWorkspaceCleanupScanToken = 0 +let finalizedWorkspaceCleanupScanToken = 0 +let workspaceCleanupProgressQueue: { + scanToken: number + promise: Promise +} | null = null let workspaceCleanupEnrichmentCache: { scanToken: number entries: Map @@ -160,10 +165,12 @@ export const createWorkspaceCleanupSlice: StateCreator { const scan = await window.api.workspaceCleanup.scan(scanArgs, (progress) => { - void applyWorkspaceCleanupProgress(progress, scanToken, get, set) + enqueueWorkspaceCleanupProgress(progress, scanToken, get, set) }) const enriched = await enrichWorkspaceCleanupCandidatesForScan( scan.candidates, @@ -172,10 +179,11 @@ export const createWorkspaceCleanupSlice: StateCreator 0) { + invalidateWorkspaceCleanupScanProgress() const removedIdSet = new Set(removedIds) set((state) => ({ + workspaceCleanupLoading: false, workspaceCleanupScan: state.workspaceCleanupScan ? { ...state.workspaceCleanupScan, @@ -327,6 +337,42 @@ function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string { }) } +function invalidateWorkspaceCleanupScanProgress(): void { + latestWorkspaceCleanupScanToken += 1 + finalizedWorkspaceCleanupScanToken = 0 + inFlightWorkspaceCleanupScan = null + workspaceCleanupProgressQueue = null + workspaceCleanupEnrichmentCache = null +} + +function enqueueWorkspaceCleanupProgress( + progress: WorkspaceCleanupScanProgress, + scanToken: number, + getState: () => AppState, + setState: ( + partial: Partial | ((state: AppState) => Partial), + replace?: false + ) => void +): void { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { + return + } + const previous = + workspaceCleanupProgressQueue?.scanToken === scanToken + ? workspaceCleanupProgressQueue.promise + : Promise.resolve() + const promise = previous + .catch(() => undefined) + .then(() => applyWorkspaceCleanupProgress(progress, scanToken, getState, setState)) + .catch((error: unknown) => { + console.error('Workspace cleanup progress update failed', error) + }) + workspaceCleanupProgressQueue = { scanToken, promise } +} + async function applyWorkspaceCleanupProgress( progress: WorkspaceCleanupScanProgress, scanToken: number, @@ -351,7 +397,10 @@ async function applyWorkspaceCleanupProgress( progress.candidateMode === 'append' ? appendWorkspaceCleanupProgressCandidates(previousCandidates, enrichedProgressCandidates) : enrichedProgressCandidates - if (scanToken !== latestWorkspaceCleanupScanToken) { + if ( + scanToken !== latestWorkspaceCleanupScanToken || + scanToken === finalizedWorkspaceCleanupScanToken + ) { return } setState((state) => { diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 7b31b7a4b69..1f2ce461ba2 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -182,7 +182,7 @@ export type WorktreeSlice = { // 'forget-local' drops the workspace from Orca only (no remote Git/FS work) // for workspaces pinned to a removed/disconnected SSH host. Reuses the same // renderer-side teardown/purge as a normal remove. - options?: { mode?: 'remove' | 'forget-local' } + options?: { mode?: 'remove' | 'forget-local'; suppressPreservedBranchToast?: boolean } ) => Promise<({ ok: true } & RemoveWorktreeResult) | { ok: false; error: string }> markWorktreesDeleting: (worktreeIds: readonly string[]) => void markWorktreesQueuedForDeletion: (worktreeIds: readonly string[]) => void diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 73979130197..d6fb11a315b 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3509,7 +3509,7 @@ export const createWorktreeSlice: StateCreator // prune effect cannot be the only stale-draft cleanup path. clearSessionCommitDraftForWorktree(worktreeId) const preservedBranch = removalResult?.preservedBranch - if (preservedBranch) { + if (preservedBranch && options?.suppressPreservedBranchToast !== true) { showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead) }) @@ -3568,7 +3568,7 @@ export const createWorktreeSlice: StateCreator let changed = false for (const worktreeId of new Set(worktreeIds)) { const current = nextDeleteState[worktreeId] - if (current?.isDeleting && current.error === null && current.phase === 'queued') { + if (current?.isDeleting && current.error === null && !current.canForceDelete) { continue } nextDeleteState[worktreeId] = {