Improve workspace cleanup list (#7053)

* Improve workspace cleanup list

Co-authored-by: Orca <help@stably.ai>

* Address workspace cleanup review feedback

Co-authored-by: Orca <help@stably.ai>

* Fix workspace cleanup perf findings

Co-authored-by: Orca <help@stably.ai>

* Avoid stale cleanup progress cache

Co-authored-by: Orca <help@stably.ai>

* Complete workspace cleanup perf fixes

Co-authored-by: Orca <help@stably.ai>

* Fix worktree list option forwarding

Co-authored-by: Orca <help@stably.ai>

* Address workspace cleanup review nits

Co-authored-by: Orca <help@stably.ai>

* Fix workspace cleanup removal review findings

- Fail a queued removal that now needs a force the user never approved
  (confirm-time approvedCandidates snapshot compared in preflight)
- Reword the 120s removal timeout to say removal continues in background
- Wire suppressPreservedBranchToast into cleanup removals
- Stop statting a repo after the first activity metadata timeout
- Document the WSL 9P best-effort stat gap; drop unused locale key

Co-authored-by: Orca <help@stably.ai>

* Split workspace-cleanup slice test to satisfy max-lines

Rebasing onto latest main pushed the combined store-slice test over the
800-line cap. Extract shared fixtures into a test harness and split the
suite into scan-progress and removal-preflight files instead of adding a
forbidden max-lines suppression.

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Brennan Benson <brennanbenson@Brennans-MacBook-Pro.local>
This commit is contained in:
Brennan Benson
2026-07-09 16:24:20 -07:00
committed by GitHub
co-authored by Orca Brennan Benson
parent 9c111fd7aa
commit e7ee15f4b2
34 changed files with 4263 additions and 1355 deletions
@@ -0,0 +1,163 @@
import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { Repo, Worktree } from '../../shared/types'
import { resolveWorkspaceCleanupActivityWorktree } from './workspace-cleanup-activity'
const REPO: Repo = {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 1
}
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
return {
id: 'repo-1::/repo-feature',
repoId: 'repo-1',
path: '/repo-feature',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
linkedBitbucketPR: null,
linkedAzureDevOpsPR: null,
linkedGiteaPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
workspaceStatus: 'in-progress',
...overrides
}
}
describe('resolveWorkspaceCleanupActivityWorktree', () => {
it('uses local worktree filesystem metadata when persisted activity is missing', async () => {
const statPath = vi.fn(async (targetPath: string) => ({
mtimeMs: targetPath.endsWith('.git') ? 20_000 : 10_000
}))
const worktree = await resolveWorkspaceCleanupActivityWorktree(REPO, makeWorktree(), statPath)
expect(statPath).toHaveBeenCalledWith('/repo-feature')
expect(statPath).toHaveBeenCalledWith(path.join('/repo-feature', '.git'))
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<string, number> = {
'/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<string, number> = {
[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 }))
const worktree = await resolveWorkspaceCleanupActivityWorktree(
REPO,
makeWorktree({ lastActivityAt: 30_000 }),
statPath
)
expect(worktree.lastActivityAt).toBe(30_000)
})
it('does not stat remote worktree paths', async () => {
const statPath = vi.fn(async () => ({ mtimeMs: 20_000 }))
const worktree = await resolveWorkspaceCleanupActivityWorktree(
{ ...REPO, connectionId: 'ssh-1' },
makeWorktree({ createdAt: 10_000 }),
statPath
)
expect(statPath).not.toHaveBeenCalled()
expect(worktree.lastActivityAt).toBe(10_000)
})
})
+126
View File
@@ -0,0 +1,126 @@
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<string>
export function getPersistedWorkspaceCleanupActivityAt(
worktree: Pick<Worktree, 'createdAt' | 'lastActivityAt'>
): number {
const persistedActivityAt = Number.isFinite(worktree.lastActivityAt) ? worktree.lastActivityAt : 0
const createdAt = Number.isFinite(worktree.createdAt) ? (worktree.createdAt ?? 0) : 0
return Math.max(persistedActivityAt, createdAt)
}
export function resolvePersistedWorkspaceCleanupActivityWorktree(worktree: Worktree): Worktree {
const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree)
if (persistedActivityAt <= worktree.lastActivityAt) {
return worktree
}
return { ...worktree, lastActivityAt: persistedActivityAt }
}
export async function resolveWorkspaceCleanupActivityWorktree(
repo: Repo,
worktree: Worktree,
statPath: StatPath = statLocalPath,
readTextFile: ReadTextFile = readLocalTextFile
): Promise<Worktree> {
const activityAt = await resolveWorkspaceCleanupActivityAt(repo, worktree, statPath, readTextFile)
if (activityAt <= worktree.lastActivityAt) {
return worktree
}
return { ...worktree, lastActivityAt: activityAt }
}
async function statLocalPath(targetPath: string): Promise<{ mtimeMs: number }> {
const stats = await lstat(targetPath)
return { mtimeMs: Number(stats.mtimeMs) }
}
async function readLocalTextFile(targetPath: string): Promise<string> {
return readFile(targetPath, 'utf8')
}
async function resolveWorkspaceCleanupActivityAt(
repo: Repo,
worktree: Worktree,
statPath: StatPath,
readTextFile: ReadTextFile
): Promise<number> {
const persistedActivityAt = getPersistedWorkspaceCleanupActivityAt(worktree)
if (repo.connectionId) {
return persistedActivityAt
}
const filesystemActivityAt = await getNewestLocalWorktreeStatMtime(
worktree.path,
statPath,
readTextFile
)
return Math.max(persistedActivityAt, filesystemActivityAt)
}
// Why: best-effort only. Win32 stat over \\wsl.localhost (9P) can falsely
// report ENOENT (see wslUncDirectoryExists), so a failed stat degrades to the
// persisted activity timestamp instead of blocking or mislabeling the row.
async function getNewestLocalWorktreeStatMtime(
worktreePath: string,
statPath: StatPath,
readTextFile: ReadTextFile
): Promise<number> {
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(gitPath, statPath),
...gitDirMtimePromises
])
return Math.max(0, ...mtimes)
}
async function readLocalWorktreeGitDir(
worktreePath: string,
gitPath: string,
readTextFile: ReadTextFile
): Promise<string | null> {
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<number> {
try {
const stats = await statPath(targetPath)
return Number.isFinite(stats.mtimeMs) ? stats.mtimeMs : 0
} catch {
return 0
}
}
+73 -24
View File
@@ -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 {
@@ -13,6 +13,11 @@ import type {
WorkspaceCleanupScanProgress,
WorkspaceCleanupScanResult
} from '../../shared/workspace-cleanup'
import {
getPersistedWorkspaceCleanupActivityAt,
resolvePersistedWorkspaceCleanupActivityWorktree,
resolveWorkspaceCleanupActivityWorktree
} from './workspace-cleanup-activity'
import {
buildWorkspaceCleanupCandidate,
buildWorkspaceCleanupCandidateFromError,
@@ -124,48 +129,92 @@ async function scanRepoWorkspaces(
return { scannedAt, candidates, errors: [] }
}
const worktrees = gitWorktrees
.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = store.getWorktreeMeta(worktreeId)
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
})
.filter((worktree) => {
if (targetWorktreeId) {
return worktree.id === targetWorktreeId
}
return (
!repoIsFolder &&
!worktree.isMainWorktree &&
isWorkspaceInactiveForCleanup(worktree, scannedAt)
const mergedWorktrees = gitWorktrees.map((gitWorktree) => {
const worktreeId = `${repo.id}::${gitWorktree.path}`
const meta = store.getWorktreeMeta(worktreeId)
return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
})
const candidateWorktrees = targetWorktreeId
? mergedWorktrees.filter((worktree) => worktree.id === targetWorktreeId)
: mergedWorktrees.filter((worktree) =>
shouldResolveBroadWorkspaceCleanupActivity(repoIsFolder, worktree, scannedAt)
)
})
onWorktreesDiscovered?.(worktrees.length)
const candidates = await mapWorkspaceCleanupWithConcurrency(
worktrees,
// Why: fs stat has no cancellation, so on a hung network/WSL mount every
// timed-out row would abandon more threadpool work. After the first timeout,
// stop statting this repo and use persisted activity only.
let activityStatsUnavailable = false
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 = activityStatsUnavailable
? resolvePersistedWorkspaceCleanupActivityWorktree(worktree)
: await resolveCleanupActivityWithTimeout(repo, worktree, () => {
activityStatsUnavailable = true
})
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,
onActivityStatsUnavailable: () => void
): Promise<Worktree> {
try {
return await withWorkspaceCleanupTimeout(
() => resolveWorkspaceCleanupActivityWorktree(repo, worktree),
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
'Timed out reading worktree activity.'
)
} catch (error) {
onActivityStatsUnavailable()
console.warn('Workspace cleanup activity scan failed', error)
return resolvePersistedWorkspaceCleanupActivityWorktree(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
)
}
async function listCleanupGitWorktrees(
repo: Repo,
repoIsFolder: boolean
+221 -11
View File
@@ -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> = {}): 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,116 @@ 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<string, WorktreeMeta> = {
'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('stops statting a repo after the first activity metadata timeout', async () => {
listRepoWorktreesMock.mockResolvedValue(
['/repo-a', '/repo-b', '/repo-c', '/repo-d'].map((worktreePath, index) => ({
path: worktreePath,
head: `head-${index}`,
branch: `refs/heads/branch-${index}`,
isBare: false,
isMainWorktree: false
}))
)
lstatMock.mockReturnValue(new Promise(() => undefined))
const scanPromise = scanWorkspaceCleanup(makeStore())
await vi.advanceTimersByTimeAsync(8_000)
const result = await scanPromise
expect(result.candidates).toHaveLength(4)
expect(lstatMock).not.toHaveBeenCalledWith('/repo-d')
expect(lstatMock).not.toHaveBeenCalledWith(path.join('/repo-d', '.git'))
})
it('includes focused remove preflight rows even when they are recent', async () => {
const result = await scanWorkspaceCleanup(
makeStore({
+13 -9
View File
@@ -60,17 +60,21 @@ describe('repo-worktrees', () => {
listWorktreesMock.mockResolvedValue([
{ path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true }
])
const signal = new AbortController().signal
const result = await listRepoWorktrees({
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git'
})
const result = await listRepoWorktrees(
{
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git'
},
{ signal }
)
expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo')
expect(listWorktreesMock).toHaveBeenCalledWith('/workspace/repo', { signal })
expect(result).toHaveLength(1)
})
+6 -2
View File
@@ -9,6 +9,10 @@ type LocalRepoWorktreeListOptions = {
signal?: AbortSignal
}
function hasLocalRepoWorktreeListOptions(options: LocalRepoWorktreeListOptions | undefined) {
return options?.wslDistro !== undefined || options?.signal !== undefined
}
export function isRepoRoot(repos: Repo[], resolvedTarget: string): boolean {
return repos.some(
(repo) => !repo.connectionId && areWorktreePathsEqual(repo.path, resolvedTarget)
@@ -30,7 +34,7 @@ export function createFolderWorktree(repo: Repo): GitWorktreeInfo {
export async function listRepoWorktrees(
repo: Repo,
options: LocalRepoWorktreeListOptions = {}
options?: LocalRepoWorktreeListOptions
): Promise<GitWorktreeInfo[]> {
if (isFolderRepo(repo)) {
return [createFolderWorktree(repo)]
@@ -42,7 +46,7 @@ export async function listRepoWorktrees(
// local git against a server path.
return provider ? await provider.listWorktrees(repo.path) : []
}
return options.wslDistro
return hasLocalRepoWorktreeListOptions(options)
? await listWorktrees(repo.path, options)
: await listWorktrees(repo.path)
}
+23 -1
View File
@@ -24,7 +24,11 @@ type GitBufferSpyTarget = {
}
type GitSpyTarget = {
git(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }>
git(
args: string[],
cwd: string,
opts?: { signal?: AbortSignal }
): Promise<{ stdout: string; stderr: string }>
}
function deferredRelayBuffer(content: string): {
@@ -1882,6 +1886,24 @@ describe('GitHandler', () => {
expect(result[0].isMainWorktree).toBe(true)
})
it('passes request cancellation to the git worktree list subprocess', async () => {
const controller = new AbortController()
const gitSpy = vi
.spyOn(handler as unknown as GitSpyTarget, 'git')
.mockRejectedValue(new Error('aborted'))
const result = await dispatcher.callRequest(
'git.listWorktrees',
{ repoPath: tmpDir },
{ isStale: () => false, signal: controller.signal }
)
expect(result).toEqual([])
expect(gitSpy).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], tmpDir, {
signal: controller.signal
})
})
it.skipIf(process.platform === 'win32')(
'normalizes the main worktree path for a separate-git-dir repo',
async () => {
+8 -4
View File
@@ -216,7 +216,7 @@ export class GitHandler {
this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p))
this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p))
this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p))
this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p))
this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context))
this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p))
this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p))
this.dispatcher.onRequest('git.worktreeIsClean', (p) => this.worktreeIsClean(p))
@@ -1263,10 +1263,12 @@ export class GitHandler {
return normalized
}
private async listWorktrees(params: Record<string, unknown>) {
private async listWorktrees(params: Record<string, unknown>, context?: RequestContext) {
const repoPath = params.repoPath as string
try {
const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath)
const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, {
signal: context?.signal
})
return this.normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true })
@@ -1280,7 +1282,9 @@ export class GitHandler {
// Why: `-z` keeps newline-containing SSH worktree paths intact, but older
// Git rejects it. Fall back to the original line-block parser there.
try {
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath)
const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, {
signal: context?.signal
})
return this.normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout))
} catch {
return []
@@ -606,6 +606,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
const legacyCardTitleDisplay = coerceWorktreeCardVisibleTitle(worktree.displayName)
const visibleCardTitle = newCardStyle ? cardTitleDisplay : legacyCardTitleDisplay
const isDeleting = deleteState?.isDeleting ?? false
const isQueuedForDeletion = deleteState?.phase === 'queued'
const deleteLabel = isQueuedForDeletion
? translate('auto.components.sidebar.WorktreeCard.ef18787206', 'Queued for deletion')
: translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…')
const deleteModifierPressed = useWorkspaceDeleteModifierPressed()
const showStatus = cardProps.includes('status')
@@ -1877,8 +1881,10 @@ const WorktreeCard = React.memo(function WorktreeCard({
{isDeleting && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/50 backdrop-blur-[1px]">
<div className="inline-flex items-center gap-1.5 rounded-full bg-background px-3 py-1 text-[11px] font-medium text-foreground shadow-sm border border-border/50">
<LoaderCircle className="size-3.5 animate-spin text-muted-foreground" />
{translate('auto.components.sidebar.WorktreeCard.691ccfd622', 'Deleting…')}
{!isQueuedForDeletion ? (
<LoaderCircle className="size-3.5 animate-spin text-muted-foreground" />
) : null}
{deleteLabel}
</div>
</div>
)}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,389 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import {
startWorkspaceCleanupBackgroundRemoval,
type WorkspaceCleanupBackgroundRemovalArgs
} from './workspace-cleanup-background-removal'
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
vi.mock('sonner', () => ({
toast: {
error: vi.fn(),
success: vi.fn()
}
}))
async function settleBackgroundRemoval(): Promise<void> {
for (let index = 0; index < 10; index += 1) {
await Promise.resolve()
}
}
describe('startWorkspaceCleanupBackgroundRemoval', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.useRealTimers()
})
it('reports an empty result without starting removal when there are no candidates', async () => {
const removeCandidates = vi.fn()
const onProgress = vi.fn()
const onResult = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [],
removeCandidates,
onProgress,
onResult
})
await settleBackgroundRemoval()
expect(removeCandidates).not.toHaveBeenCalled()
expect(onProgress).not.toHaveBeenCalled()
expect(onResult).toHaveBeenCalledWith({ removedIds: [], failures: [] })
expect(toast.success).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
})
it('reports deletion progress while the slow removal promise is pending', async () => {
let resolveRemoval: (
result: Awaited<ReturnType<WorkspaceCleanupBackgroundRemovalArgs['removeCandidates']>>
) => void
const removeCandidates = vi.fn(
() =>
new Promise<Awaited<ReturnType<WorkspaceCleanupBackgroundRemovalArgs['removeCandidates']>>>(
(resolve) => {
resolveRemoval = resolve
}
)
)
const onProgress = vi.fn()
const onResult = vi.fn()
const candidate = makeCandidate()
startWorkspaceCleanupBackgroundRemoval({
candidates: [candidate],
removeCandidates,
onProgress,
onResult
})
expect(removeCandidates).toHaveBeenCalledWith([candidate.worktreeId], {
approvedCandidates: [candidate]
})
expect(onProgress).toHaveBeenCalledWith({
totalCount: 1,
processedCount: 0,
removedCount: 0,
failedCount: 0
})
expect(onResult).not.toHaveBeenCalled()
resolveRemoval!({ removedIds: [candidate.worktreeId], failures: [] })
await settleBackgroundRemoval()
expect(onProgress).toHaveBeenLastCalledWith({
totalCount: 1,
processedCount: 1,
removedCount: 1,
failedCount: 0
})
expect(toast.success).toHaveBeenCalled()
expect(onResult).toHaveBeenCalledWith({ removedIds: [candidate.worktreeId], failures: [] })
})
it('removes candidates one at a time for per-row progress', async () => {
const first = makeCandidate()
const second = makeCandidate({
worktreeId: 'repo-1::/repo/beta',
displayName: 'beta',
branch: 'beta',
path: '/repo/beta'
})
const removeCandidates = vi
.fn()
.mockResolvedValueOnce({ removedIds: [first.worktreeId], failures: [] })
.mockResolvedValueOnce({ removedIds: [second.worktreeId], failures: [] })
const onProgress = vi.fn()
startWorkspaceCleanupBackgroundRemoval({
candidates: [first, second],
removeCandidates,
onProgress
})
await settleBackgroundRemoval()
expect(removeCandidates).toHaveBeenNthCalledWith(1, [first.worktreeId], {
approvedCandidates: [first]
})
expect(removeCandidates).toHaveBeenNthCalledWith(2, [second.worktreeId], {
approvedCandidates: [second]
})
expect(onProgress).toHaveBeenLastCalledWith({
totalCount: 2,
processedCount: 2,
removedCount: 2,
failedCount: 0
})
})
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], {
approvedCandidates: [child]
})
expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], {
approvedCandidates: [parent]
})
})
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], {
approvedCandidates: [child]
})
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], {
approvedCandidates: [failedChild]
})
expect(removeCandidates).toHaveBeenNthCalledWith(2, [unrelatedParent.worktreeId], {
approvedCandidates: [unrelatedParent]
})
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()
startWorkspaceCleanupBackgroundRemoval({
candidates: [candidate],
removeCandidates: vi.fn().mockResolvedValue({
removedIds: [],
failures: [
{ worktreeId: candidate.worktreeId, displayName: candidate.displayName, message: 'busy' }
]
}),
onProgress: vi.fn()
})
await settleBackgroundRemoval()
expect(toast.error).toHaveBeenCalledWith(
'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<ReturnType<WorkspaceCleanupBackgroundRemovalArgs['removeCandidates']>>
>(() => 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:
'Removing alpha is taking longer than expected. It will keep running in the background.'
}
]
})
})
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' })
)
})
})
@@ -0,0 +1,216 @@
import { toast } from 'sonner'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../../../../shared/cross-platform-path'
import type {
WorkspaceCleanupFailure,
WorkspaceCleanupRemoveOptions,
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
removedCount: number
failedCount: number
}
export type WorkspaceCleanupBackgroundRemovalArgs = {
candidates: readonly WorkspaceCleanupCandidate[]
removeCandidates: (
worktreeIds: readonly string[],
options?: WorkspaceCleanupRemoveOptions
) => Promise<WorkspaceCleanupRemoveResult>
onProgress: (progress: WorkspaceCleanupRemovalProgress) => void
onResult?: (result: WorkspaceCleanupRemoveResult) => void
onError?: (error: unknown) => void
removalTimeoutMs?: number
}
export function startWorkspaceCleanupBackgroundRemoval({
candidates,
removeCandidates,
onProgress,
onResult,
onError,
removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS
}: WorkspaceCleanupBackgroundRemovalArgs): void {
if (candidates.length === 0) {
try {
onResult?.({ removedIds: [], failures: [] })
} catch (callbackError) {
console.error('Workspace cleanup result callback failed', callbackError)
}
return
}
const count = candidates.length
const removedIds: string[] = []
const failures: WorkspaceCleanupFailure[] = []
const failedCandidates: WorkspaceCleanupCandidate[] = []
let processedCount = 0
const emitProgress = (): void => {
onProgress({
totalCount: count,
processedCount,
removedCount: removedIds.length,
failedCount: failures.length
})
}
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 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 withWorkspaceCleanupRemovalTimeout(
removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate] }),
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,
message: error instanceof Error ? error.message : String(error)
})
} finally {
processedCount += 1
emitProgress()
}
}
const result = { removedIds, failures }
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 workspaces: {{value0}}',
{
value0: result.removedIds.length
}
)
)
}
if (result.failures.length > 0) {
toast.error(
translate(
'auto.components.workspace.cleanup.backgroundRemoval.failed',
'Workspaces not removed: {{value0}}',
{
value0: result.failures.length
}
),
{
description: result.failures.map((failure) => failure.message).join('; ')
}
)
}
})().catch((error: unknown) => {
onError?.(error)
toast.error(
translate(
'auto.components.workspace.cleanup.backgroundRemoval.error',
'Workspace cleanup failed'
),
{
description: error instanceof Error ? error.message : String(error)
}
)
})
}
async function withWorkspaceCleanupRemovalTimeout(
promise: Promise<WorkspaceCleanupRemoveResult>,
candidate: WorkspaceCleanupCandidate,
timeoutMs: number
): Promise<WorkspaceCleanupRemoveResult> {
if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) {
return promise
}
let timeout: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
promise,
new Promise<WorkspaceCleanupRemoveResult>((_resolve, reject) => {
timeout = setTimeout(() => {
// Why: the underlying removal cannot be cancelled from the renderer,
// so the row stays "Deleting" and this message must not claim the
// removal stopped.
reject(
new Error(
translate(
'auto.components.workspace.cleanup.backgroundRemoval.timedOut',
'Removing {{value0}} is taking longer than expected. It will keep running in the background.',
{ 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)
)
}
@@ -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 }
)
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { getDirtyGitLabel, shouldShowGitMetadataChip } from './workspace-cleanup-candidate-row-data'
import { makeCandidate } from './workspace-cleanup-presentation-fixtures'
describe('workspace cleanup candidate row data', () => {
it('does not duplicate git status blockers as a separate git icon label', () => {
const gitStatusError = makeCandidate({
blockers: ['git-status-error'],
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null }
})
const unknownBase = makeCandidate({
blockers: ['unknown-base'],
git: { clean: true, upstreamAhead: null, upstreamBehind: null, checkedAt: 1 }
})
expect(getDirtyGitLabel(gitStatusError)).toBeNull()
expect(shouldShowGitMetadataChip(gitStatusError)).toBe(false)
expect(getDirtyGitLabel(unknownBase)).toBeNull()
expect(shouldShowGitMetadataChip(unknownBase)).toBe(false)
})
it('keeps the git metadata chip for ordinary clean rows', () => {
expect(
shouldShowGitMetadataChip(
makeCandidate({
git: { clean: true, upstreamAhead: 0, upstreamBehind: 0, checkedAt: 1 }
})
)
).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(false)
})
})
@@ -0,0 +1,204 @@
import { translate } from '@/i18n/i18n'
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'
export function getWorkspaceCleanupBlockerLabels(candidate: WorkspaceCleanupCandidate): string[] {
return candidate.blockers.map((blocker) => getWorkspaceCleanupBlockerLabel(blocker))
}
export function getCandidateStatus(candidate: WorkspaceCleanupCandidate): {
label: string
tone: StatusPillTone
} {
if (candidate.blockers.includes('dismissed')) {
return {
label: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7',
'Ignored'
),
tone: 'neutral'
}
}
if (candidate.tier === 'ready') {
return {
label: candidate.reasons.includes('archived')
? translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.archivedStatus',
'Archived'
)
: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.readyStatus',
'Ready'
),
tone: 'ready'
}
}
if (candidate.blockers.length > 0) {
return { label: getWorkspaceCleanupBlockerLabel(candidate.blockers[0]), tone: 'neutral' }
}
if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) {
return {
label: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9623a5107d',
'Unpushed commits'
),
tone: 'review'
}
}
if (candidate.git.clean === false) {
return {
label: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e97e4580c7',
'Dirty'
),
tone: 'review'
}
}
if (candidate.tier === 'review') {
return {
label: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0a2e3c7cba',
'Review'
),
tone: 'review'
}
}
return {
label: translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.c4f4782c02',
'Not suggested'
),
tone: 'neutral'
}
}
export function formatGitStatus(candidate: WorkspaceCleanupCandidate): string {
return formatWorkspaceCleanupGitStatusLabel(getWorkspaceCleanupGitLabel(candidate))
}
export function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate): string[] {
const details: string[] = []
if (candidate.git.upstreamAhead !== null) {
details.push(
candidate.git.upstreamAhead === 0
? getNoUnpushedCommitsLabel()
: formatUnpushedCommitCount(candidate.git.upstreamAhead)
)
}
return details
}
export function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null {
const parts: string[] = []
if (candidate.localContext.terminalTabCount > 0) {
parts.push(
formatWorkspaceCleanupContextDetail('terminal', candidate.localContext.terminalTabCount)
)
}
if (candidate.localContext.cleanEditorTabCount > 0) {
parts.push(
formatWorkspaceCleanupContextDetail('editor', candidate.localContext.cleanEditorTabCount)
)
}
if (candidate.localContext.browserTabCount > 0) {
parts.push(
formatWorkspaceCleanupContextDetail('browser', candidate.localContext.browserTabCount)
)
}
if (candidate.localContext.diffCommentCount > 0) {
parts.push(formatWorkspaceCleanupContextDetail('diff', candidate.localContext.diffCommentCount))
}
if (candidate.localContext.retainedDoneAgentCount > 0) {
parts.push(
formatWorkspaceCleanupContextDetail('agent', candidate.localContext.retainedDoneAgentCount)
)
}
return parts.length > 0 ? parts.join(', ') : null
}
export function getDirtyGitLabel(candidate: WorkspaceCleanupCandidate): string | null {
if (
candidate.blockers.includes('unknown-base') ||
candidate.blockers.includes('git-status-error')
) {
return null
}
if (candidate.blockers.includes('unpushed-commits')) {
if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) {
return formatUnpushedCommitCount(candidate.git.upstreamAhead)
}
return getUnpushedCommitsLabel()
}
if (candidate.git.upstreamAhead && candidate.git.upstreamAhead > 0) {
return formatUnpushedCommitCount(candidate.git.upstreamAhead)
}
if (candidate.git.clean === false) {
return getUncommittedChangesLabel()
}
if (candidate.git.clean == null) {
return getGitStatusUnknownLabel()
}
return null
}
export function shouldShowGitMetadataChip(candidate: WorkspaceCleanupCandidate): boolean {
return (
!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'
}
return 'neutral'
}
export function getContextPillLabel(candidate: WorkspaceCleanupCandidate): string | null {
if (!hasWorkspaceCleanupLocalContext(candidate)) {
return null
}
return formatWorkspaceCleanupContextCount(getContextCount(candidate))
}
export function getContextCount(candidate: WorkspaceCleanupCandidate): number {
return (
candidate.localContext.terminalTabCount +
candidate.localContext.cleanEditorTabCount +
candidate.localContext.browserTabCount +
candidate.localContext.diffCommentCount +
candidate.localContext.retainedDoneAgentCount
)
}
@@ -0,0 +1,107 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { formatGitStatus } from './workspace-cleanup-candidate-row-data'
type CandidateRowDetailsProps = {
blockers: string[]
branchSafetyDetails: string[]
candidate: WorkspaceCleanupCandidate
contextDetails: string | null
expanded: boolean
}
export function CandidateRowDetails({
blockers,
branchSafetyDetails,
candidate,
contextDetails,
expanded
}: CandidateRowDetailsProps): React.JSX.Element {
return (
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,margin-top,opacity] duration-200 ease-out motion-reduce:transition-none',
expanded ? 'mt-2 grid-rows-[1fr] opacity-100' : 'mt-0 grid-rows-[0fr] opacity-0'
)}
aria-hidden={!expanded}
>
<div className="min-h-0 overflow-hidden">
<div className="pl-1">
<div className="grid gap-x-4 gap-y-1.5 text-xs text-muted-foreground sm:grid-cols-2">
<DetailLine
label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
'Repo'
)}
value={candidate.repoName}
/>
<DetailLine
label={translate('auto.components.workspace.cleanup.candidateRow.gitLabel', 'Git')}
value={formatGitStatus(candidate)}
/>
<DetailLine
label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.bef0adef9b',
'Branch'
)}
value={candidate.branch}
mono
/>
{branchSafetyDetails.slice(0, 1).map((detail) => (
<DetailLine
key={detail}
label={translate(
'auto.components.workspace.cleanup.candidateRow.commitsLabel',
'Commits'
)}
value={detail}
/>
))}
{contextDetails ? (
<DetailLine
label={translate(
'auto.components.workspace.cleanup.candidateRow.contextLabel',
'Context'
)}
value={contextDetails}
/>
) : null}
{blockers.length > 0 ? (
<DetailLine
label={translate(
'auto.components.workspace.cleanup.candidateRow.flagsLabel',
'Flags'
)}
value={blockers.slice(0, 2).join(', ')}
/>
) : null}
</div>
<div className="mt-2 min-w-0 truncate font-mono text-[11px] text-muted-foreground">
{candidate.path}
</div>
</div>
</div>
</div>
)
}
function DetailLine({
label,
mono = false,
value
}: {
label: string
mono?: boolean
value: string
}): React.JSX.Element {
return (
<div className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground/80">
{label}
</span>
<span className={cn('min-w-0 truncate', mono && 'font-mono text-[11px]')}>{value}</span>
</div>
)
}
@@ -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(
<CandidateRow
candidate={candidate}
expanded={false}
last
lastActivityLabel="1d ago"
removing
reviewInfo={{
hasReview: false,
label: null,
provider: null,
state: null,
title: null
}}
selected
onIgnore={vi.fn()}
onRemove={vi.fn()}
onToggleExpanded={vi.fn()}
onToggleSelected={vi.fn()}
onView={vi.fn()}
/>
)
})
expect(container?.querySelector(`[aria-label="Select ${candidate.displayName}"]`)).toBeNull()
expect(container?.querySelector(`[aria-label="Remove ${candidate.displayName}"]`)).toBeNull()
})
})
@@ -0,0 +1,342 @@
import React from 'react'
import {
AlertTriangle,
Check,
ChevronDown,
Clock3,
EyeOff,
FileWarning,
GitBranch,
GitPullRequest,
Search,
SquareTerminal,
Trash2,
type LucideIcon
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import {
canQueueWorkspaceCleanupCandidate,
type WorkspaceCleanupCandidate
} from '../../../../shared/workspace-cleanup'
import {
getWorkspaceCleanupGitLabel,
type WorkspaceCleanupReviewInfo
} from './workspace-cleanup-presentation'
import { CandidateRowDetails } from './workspace-cleanup-candidate-row-details'
import {
formatBranchSafetyDetails,
formatContextDetails,
formatGitStatus,
getCandidateStatus,
getContextCount,
getDirtyGitLabel,
getReviewPillTone,
getWorkspaceCleanupBlockerLabels,
shouldShowGitMetadataChip,
type StatusPillTone
} from './workspace-cleanup-candidate-row-data'
import { StatusPill } from './workspace-cleanup-status-pill'
type CandidateRowProps = {
candidate: WorkspaceCleanupCandidate
expanded: boolean
failure?: string
last: boolean
lastActivityLabel: string
removing?: boolean
reviewInfo: WorkspaceCleanupReviewInfo
selected: boolean
onIgnore: (candidate: WorkspaceCleanupCandidate) => void
onRemove: (candidate: WorkspaceCleanupCandidate) => void
onToggleExpanded: (worktreeId: string) => void
onToggleSelected: (worktreeId: string) => void
onView: (candidate: WorkspaceCleanupCandidate) => void
}
function MetadataIconChip({
icon: Icon,
label,
value,
tone = 'neutral'
}: {
icon: LucideIcon
label: string
value?: string
tone?: StatusPillTone
}): React.JSX.Element {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex h-5 shrink-0 items-center gap-1 rounded-full border px-1.5 text-[11px] font-medium',
'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' && 'bg-muted text-foreground',
tone === 'destructive' && 'border-destructive/30 text-destructive'
)}
aria-label={label}
>
<Icon className="size-3" aria-hidden="true" />
{value ? <span>{value}</span> : null}
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{label}
</TooltipContent>
</Tooltip>
)
}
export function CandidateRow({
candidate,
expanded,
failure,
last,
lastActivityLabel,
removing = false,
reviewInfo,
selected,
onIgnore,
onRemove,
onToggleExpanded,
onToggleSelected,
onView
}: CandidateRowProps): React.JSX.Element {
const selectable = canQueueWorkspaceCleanupCandidate(candidate) && !removing
const ignored = candidate.blockers.includes('dismissed')
const blockers = getWorkspaceCleanupBlockerLabels(candidate)
const contextDetails = formatContextDetails(candidate)
const branchSafetyDetails = formatBranchSafetyDetails(candidate)
const status = getCandidateStatus(candidate)
const dirtyLabel = getDirtyGitLabel(candidate)
const showGitMetadataChip = shouldShowGitMetadataChip(candidate)
const contextCount = getContextCount(candidate)
const hasExpandableDetails =
blockers.length > 0 ||
candidate.path.length > 0 ||
candidate.branch.length > 0 ||
contextDetails !== null ||
branchSafetyDetails.length > 0
return (
<div
className={cn(
'group w-full border-b border-border/60 px-3 py-2.5 text-left text-foreground transition-colors hover:bg-accent/40',
selected && 'bg-accent/30',
last && 'border-b-0'
)}
>
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-2.5 gap-y-1">
{selectable ? (
<button
type="button"
role="checkbox"
aria-checked={selected}
aria-label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.bbb1ab6a6f',
'Select {{value0}}',
{ value0: candidate.displayName }
)}
onClick={() => onToggleSelected(candidate.worktreeId)}
className="mt-0.5 flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background text-primary hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
{selected ? <Check className="size-3" strokeWidth={3} /> : null}
</button>
) : (
<div className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
)}
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="min-w-0 truncate text-sm font-medium">{candidate.displayName}</span>
<StatusPill tone={status.tone}>{status.label}</StatusPill>
<MetadataIconChip
icon={Clock3}
label={`${translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.352f15d6fc',
'Last active'
)} ${lastActivityLabel}`}
value={formatCompactActivityLabel(lastActivityLabel)}
/>
{dirtyLabel && showGitMetadataChip ? (
<MetadataIconChip icon={FileWarning} label={dirtyLabel} tone="destructive" />
) : showGitMetadataChip ? (
<MetadataIconChip
icon={GitBranch}
label={formatGitStatus(candidate)}
tone={getWorkspaceCleanupGitLabel(candidate) === 'Clean' ? 'ready' : 'review'}
/>
) : null}
{contextDetails ? (
<MetadataIconChip
icon={SquareTerminal}
label={contextDetails}
value={String(contextCount)}
/>
) : null}
{reviewInfo.label ? (
<MetadataIconChip
icon={GitPullRequest}
label={getReviewTooltip(reviewInfo)}
value={reviewInfo.label}
tone={getReviewPillTone(reviewInfo)}
/>
) : null}
</div>
{failure ? (
<div className="mt-2 flex items-center gap-1.5 text-xs text-destructive">
<AlertTriangle className="size-3.5" />
{failure}
</div>
) : null}
{hasExpandableDetails ? (
<CandidateRowDetails
blockers={blockers}
branchSafetyDetails={branchSafetyDetails}
candidate={candidate}
contextDetails={contextDetails}
expanded={expanded}
/>
) : null}
</div>
<div className="flex shrink-0 items-center gap-0.5">
{hasExpandableDetails ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
aria-label={
expanded
? translate(
'auto.components.workspace.cleanup.candidateRow.collapseDetails',
'Collapse details'
)
: translate(
'auto.components.workspace.cleanup.candidateRow.expandDetails',
'Expand details'
)
}
aria-expanded={expanded}
onClick={() => onToggleExpanded(candidate.worktreeId)}
>
<ChevronDown
className={cn('size-3.5 transition-transform', expanded && 'rotate-180')}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{expanded
? translate(
'auto.components.workspace.cleanup.candidateRow.collapseDetails',
'Collapse details'
)
: translate(
'auto.components.workspace.cleanup.candidateRow.expandDetails',
'Expand details'
)}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1bffc07ba7',
'View {{value0}}',
{ value0: candidate.displayName }
)}
onClick={() => onView(candidate)}
>
<Search className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.ee81adfcef',
'View'
)}
</TooltipContent>
</Tooltip>
{!ignored ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.a9957007eb',
'Ignore {{value0}}',
{ value0: candidate.displayName }
)}
onClick={() => onIgnore(candidate)}
>
<EyeOff className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4d0b72481c',
'Ignore'
)}
</TooltipContent>
</Tooltip>
) : null}
{selectable ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.3828408538',
'Remove {{value0}}',
{ value0: candidate.displayName }
)}
className="text-destructive hover:text-destructive"
onClick={() => onRemove(candidate)}
>
<Trash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.9cc26c019d',
'Remove'
)}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
</div>
)
}
function formatCompactActivityLabel(label: string): string {
if (label === 'Just now') {
return 'now'
}
return label.replace(/ ago$/, '')
}
function getReviewTooltip(reviewInfo: WorkspaceCleanupReviewInfo): string {
const parts = [reviewInfo.label]
if (reviewInfo.state) {
parts.push(reviewInfo.state)
}
if (reviewInfo.title) {
parts.push(reviewInfo.title)
}
return parts.filter(Boolean).join(' · ')
}
@@ -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])
})
})
@@ -0,0 +1,13 @@
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import type { WorktreeDeleteState } from '@/store/slices/worktrees'
type DeletionFlagState = Pick<WorktreeDeleteState, 'isDeleting'>
export function filterWorkspaceCleanupRemovalCandidates(
candidates: readonly WorkspaceCleanupCandidate[],
deleteStateByWorktreeId: Record<string, DeletionFlagState | undefined>
): WorkspaceCleanupCandidate[] {
return candidates.filter(
(candidate) => deleteStateByWorktreeId[candidate.worktreeId]?.isDeleting !== true
)
}
@@ -0,0 +1,26 @@
import React from 'react'
import { cn } from '@/lib/utils'
import type { StatusPillTone } from './workspace-cleanup-candidate-row-data'
export function StatusPill({
children,
tone = 'neutral'
}: {
children: React.ReactNode
tone?: StatusPillTone
}): React.JSX.Element {
return (
<span
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-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'
)}
>
{children}
</span>
)
}
+59 -3
View File
@@ -210,7 +210,8 @@
},
"workspace": {
"cleanup": {
"9d6e531da6": "Workspace no longer exists."
"9d6e531da6": "Workspace no longer exists.",
"changedSinceConfirmation": "Workspace changed after confirmation. Refresh to review it before removing."
}
},
"worktrees": {
@@ -2339,7 +2340,61 @@
"gitFilter": "Git",
"contextFilter": "Context",
"sortBy": "Sort by",
"sortDirection": "Direction"
"sortDirection": "Direction",
"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": "Removing {{value0}} is taking longer than expected. It will keep running in the background."
},
"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"
+59 -3
View File
@@ -210,7 +210,8 @@
},
"workspace": {
"cleanup": {
"9d6e531da6": "El espacio de trabajo ya no existe."
"9d6e531da6": "El espacio de trabajo ya no existe.",
"changedSinceConfirmation": "El espacio de trabajo cambió después de la confirmación. Actualiza para revisarlo antes de eliminarlo."
}
},
"worktrees": {
@@ -2339,7 +2340,61 @@
"gitFilter": "Git",
"contextFilter": "Contexto",
"sortBy": "Ordenar por",
"sortDirection": "Dirección"
"sortDirection": "Dirección",
"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": "La eliminación de {{value0}} está tardando más de lo esperado. Continuará ejecutándose en segundo plano."
},
"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"
+59 -3
View File
@@ -210,7 +210,8 @@
},
"workspace": {
"cleanup": {
"9d6e531da6": "ワークスペースはもう存在しません。"
"9d6e531da6": "ワークスペースはもう存在しません。",
"changedSinceConfirmation": "確認後にワークスペースが変更されました。削除する前に更新して確認してください。"
}
},
"worktrees": {
@@ -2339,7 +2340,61 @@
"gitFilter": "Git",
"contextFilter": "コンテクスト",
"sortBy": "並べ替え",
"sortDirection": "方向"
"sortDirection": "方向",
"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": "エージェント"
+59 -3
View File
@@ -210,7 +210,8 @@
},
"workspace": {
"cleanup": {
"9d6e531da6": "워크스페이스가 더 이상 존재하지 않습니다."
"9d6e531da6": "워크스페이스가 더 이상 존재하지 않습니다.",
"changedSinceConfirmation": "확인 후 워크스페이스가 변경되었습니다. 제거하기 전에 새로 고쳐 검토하세요."
}
},
"worktrees": {
@@ -2339,7 +2340,61 @@
"gitFilter": "Git",
"contextFilter": "문맥",
"sortBy": "정렬 기준",
"sortDirection": "방향"
"sortDirection": "방향",
"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": "에이전트"
+59 -3
View File
@@ -210,7 +210,8 @@
},
"workspace": {
"cleanup": {
"9d6e531da6": "工作区不再存在。"
"9d6e531da6": "工作区不再存在。",
"changedSinceConfirmation": "工作区在确认后发生了更改。请刷新并在删除前重新检查。"
}
},
"worktrees": {
@@ -2339,7 +2340,61 @@
"gitFilter": "Git",
"contextFilter": "上下文",
"sortBy": "排序依据",
"sortDirection": "方向"
"sortDirection": "方向",
"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": "智能体"
@@ -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'
@@ -270,6 +301,44 @@ describe('removeWorktree cascade', () => {
})
})
it('marks multiple worktrees queued for deletion in one optimistic state update', () => {
const store = createTestStore()
const first = 'repo1::/path/wt1'
const second = 'repo1::/path/wt2'
seedStore(store, {
deleteStateByWorktreeId: {
[first]: { isDeleting: false, error: 'old failure', canForceDelete: true }
}
})
store.getState().markWorktreesQueuedForDeletion([first, second, first])
expect(store.getState().deleteStateByWorktreeId).toMatchObject({
[first]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false },
[second]: { isDeleting: true, phase: 'queued', error: null, canForceDelete: false }
})
})
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'
@@ -0,0 +1,431 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type { WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup'
import { enrichWorkspaceCleanupCandidates } from './workspace-cleanup'
import {
NOW,
WORKTREE_ID,
createCleanupTestStore,
installWorkspaceCleanupApi,
makeCandidate,
makeState
} from './workspace-cleanup-slice-test-harness'
describe('workspace cleanup removal and protection', () => {
it('preflights cleanup removals concurrently and deletes nested workspaces globally deepest first', async () => {
let activePreflights = 0
let maxActivePreflights = 0
let activeDeletes = 0
let maxActiveDeletes = 0
const deleteOrder: string[] = []
const candidates = [
makeCandidate({
worktreeId: 'repo-a::/repo/parent',
repoId: 'repo-a',
path: '/repo/parent',
displayName: 'parent'
}),
makeCandidate({
worktreeId: 'repo-b::/repo/parent/child',
repoId: 'repo-b',
path: '/repo/parent/child',
displayName: 'child'
}),
makeCandidate({
worktreeId: 'repo-c::/other',
repoId: 'repo-c',
path: '/other',
displayName: 'other',
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null },
blockers: ['git-status-error']
})
]
const candidateById = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate]))
const scan = vi.fn(async (args?: { worktreeId?: string }) => {
activePreflights += 1
maxActivePreflights = Math.max(maxActivePreflights, activePreflights)
await new Promise((resolve) => setTimeout(resolve, 5))
activePreflights -= 1
return {
scannedAt: NOW,
candidates: args?.worktreeId ? [candidateById.get(args.worktreeId)!] : [],
errors: []
} satisfies WorkspaceCleanupScanResult
})
installWorkspaceCleanupApi(scan)
const removeWorktree = vi.fn(async (worktreeId: string) => {
deleteOrder.push(worktreeId)
activeDeletes += 1
maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes)
await new Promise((resolve) => setTimeout(resolve, 5))
activeDeletes -= 1
return { ok: true as const }
})
const store = createCleanupTestStore(removeWorktree)
store.setState({
workspaceCleanupScan: { scannedAt: NOW, candidates, errors: [] }
} as Partial<AppState>)
await expect(
store
.getState()
.removeWorkspaceCleanupCandidates(candidates.map((candidate) => candidate.worktreeId))
).resolves.toEqual({
removedIds: expect.arrayContaining(candidates.map((candidate) => candidate.worktreeId)),
failures: []
})
expect(maxActivePreflights).toBeGreaterThan(1)
expect(maxActiveDeletes).toBe(1)
expect(deleteOrder).toEqual([
'repo-b::/repo/parent/child',
'repo-a::/repo/parent',
'repo-c::/other'
])
expect(removeWorktree).toHaveBeenCalledWith('repo-c::/other', true, {
suppressPreservedBranchToast: true
})
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([])
})
it('demotes an active suggested workspace when it was not viewed from cleanup', async () => {
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({ activeWorktreeId: WORKTREE_ID }),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.blockers).toContain('active-workspace')
})
it('keeps a viewed active workspace visible but not removable', async () => {
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
activeWorktreeId: WORKTREE_ID,
workspaceCleanupViewedCandidates: {
[WORKTREE_ID]: {
viewedAt: Date.now(),
fingerprint: 'fingerprint-1',
wasSuggested: true
}
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('active-workspace')
})
it('does not preserve the cleanup view exception after the row changes', async () => {
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate({ fingerprint: 'fingerprint-2' })],
makeState({
activeWorktreeId: WORKTREE_ID,
workspaceCleanupViewedCandidates: {
[WORKTREE_ID]: {
viewedAt: Date.now(),
fingerprint: 'fingerprint-1',
wasSuggested: true
}
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.blockers).toContain('active-workspace')
})
it('protects recently visible old workspaces with open context', async () => {
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
openFiles: [
{
id: 'file-1',
worktreeId: WORKTREE_ID,
filePath: '/tmp/old-workspace/src/app.ts',
relativePath: 'src/app.ts',
language: 'typescript',
isDirty: false
}
] as AppState['openFiles'],
lastVisitedAtByWorktreeId: {
[WORKTREE_ID]: Date.now()
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('recent-visible-context')
})
it('uses current renderer state after async delete preflight scan resolves', async () => {
let resolveScan: (value: WorkspaceCleanupScanResult) => void
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan: vi.fn(
(): Promise<WorkspaceCleanupScanResult> =>
new Promise<WorkspaceCleanupScanResult>((resolve) => {
resolveScan = resolve
})
),
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
hasKillableProcesses: false
})
}
}
}
const removal = store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])
store.setState({ activeWorktreeId: WORKTREE_ID })
resolveScan!({ scannedAt: NOW, candidates: [makeCandidate()], errors: [] })
await expect(removal).resolves.toEqual({
removedIds: [WORKTREE_ID],
failures: []
})
expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, {
suppressPreservedBranchToast: true
})
})
it('defers git checks for locally active workspaces on initial scans', async () => {
const scan = vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [],
errors: []
} satisfies WorkspaceCleanupScanResult)
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan,
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
hasKillableProcesses: false
})
}
}
}
const store = createCleanupTestStore()
store.setState({
activeWorktreeId: WORKTREE_ID,
tabsByWorktree: {
'repo1::/tmp/terminal-workspace': [
{ id: 'tab-1', title: 'zsh' }
] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
} as Partial<AppState>)
await store.getState().scanWorkspaceCleanup()
expect(scan).toHaveBeenCalledWith(
{
skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace'])
},
expect.any(Function)
)
})
it('does not defer git checks for focused remove preflights', async () => {
const scan = vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [makeCandidate()],
errors: []
} satisfies WorkspaceCleanupScanResult)
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan,
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
hasKillableProcesses: false
})
}
}
}
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
store.setState({ activeWorktreeId: 'repo1::/tmp/other-workspace' } as Partial<AppState>)
await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])
expect(scan).toHaveBeenCalledWith({ worktreeId: WORKTREE_ID })
})
it('lets explicitly selected not-suggested workspaces reach the removal path', async () => {
const scan = vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [makeCandidate()],
errors: []
} satisfies WorkspaceCleanupScanResult)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan,
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
hasKillableProcesses: true
})
}
}
}
const store = createCleanupTestStore(removeWorktree)
store.setState({
tabsByWorktree: {
[WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
} as Partial<AppState>)
await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual(
{
removedIds: [WORKTREE_ID],
failures: []
}
)
expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false, {
suppressPreservedBranchToast: true
})
})
it('fails a queued removal that now needs a force the user never approved', async () => {
const approvedCandidate = makeCandidate()
const dirtySinceConfirmation = makeCandidate({
tier: 'review',
blockers: ['dirty-files'],
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }
})
const scan = vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [dirtySinceConfirmation],
errors: []
} satisfies WorkspaceCleanupScanResult)
installWorkspaceCleanupApi(scan)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
await expect(
store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [approvedCandidate]
})
).resolves.toEqual({
removedIds: [],
failures: [
{
worktreeId: WORKTREE_ID,
displayName: 'old-workspace',
message: 'Workspace changed after confirmation. Refresh to review it before removing.'
}
]
})
expect(removeWorktree).not.toHaveBeenCalled()
})
it('still force-removes rows whose approved candidate already carried git risk', async () => {
const approvedCandidate = makeCandidate({
tier: 'review',
blockers: ['dirty-files'],
git: { clean: false, upstreamAhead: 0, upstreamBehind: 0, checkedAt: NOW }
})
const scan = vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [approvedCandidate],
errors: []
} satisfies WorkspaceCleanupScanResult)
installWorkspaceCleanupApi(scan)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
await expect(
store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID], {
approvedCandidates: [approvedCandidate]
})
).resolves.toEqual({ removedIds: [WORKTREE_ID], failures: [] })
expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, true, {
suppressPreservedBranchToast: true
})
})
it('protects old workspaces when an agent process is still foregrounded', async () => {
;(globalThis as { window: unknown }).window = {
api: {
pty: {
hasChildProcesses: vi.fn().mockResolvedValue(true),
getForegroundProcess: vi.fn().mockResolvedValue('codex')
}
}
}
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
tabsByWorktree: {
[WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('running-terminal')
})
it('does not let an idle title in another tab mask a running agent process', async () => {
;(globalThis as { window: unknown }).window = {
api: {
pty: {
hasChildProcesses: vi.fn(async (ptyId: string) => ptyId === 'pty-running'),
getForegroundProcess: vi.fn(async (ptyId: string) =>
ptyId === 'pty-running' ? 'codex' : 'zsh'
)
}
}
}
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
tabsByWorktree: {
[WORKTREE_ID]: [
{ id: 'tab-running', title: 'zsh' },
{ id: 'tab-idle', title: 'Codex done' }
] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: {
'tab-running': ['pty-running'],
'tab-idle': ['pty-idle']
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('running-terminal')
})
})
@@ -0,0 +1,598 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type {
WorkspaceCleanupScanProgress,
WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
import {
NOW,
WORKTREE_ID,
createCleanupTestStore,
deferred,
installWorkspaceCleanupApi,
makeCandidate
} from './workspace-cleanup-slice-test-harness'
describe('workspace cleanup scan progress', () => {
it('joins duplicate broad cleanup scans', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn().mockReturnValue(pending.promise)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const first = store.getState().scanWorkspaceCleanup()
const second = store.getState().scanWorkspaceCleanup()
expect(scan).toHaveBeenCalledTimes(1)
expect(store.getState().workspaceCleanupLoading).toBe(true)
const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
pending.resolve(result)
await expect(Promise.all([first, second])).resolves.toEqual([result, result])
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(1)
expect(store.getState().workspaceCleanupLoading).toBe(false)
})
it('does not leave cleanup loading stuck when a reopen joins a just-settled scan', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
const scan = vi.fn().mockReturnValue(pending.promise)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
let joinedScan: Promise<WorkspaceCleanupScanResult> | null = null
const unsubscribe = store.subscribe((state, previousState) => {
if (
previousState.workspaceCleanupLoading &&
!state.workspaceCleanupLoading &&
joinedScan === null
) {
joinedScan = state.scanWorkspaceCleanup()
}
})
const firstScan = store.getState().scanWorkspaceCleanup()
pending.resolve(result)
await expect(firstScan).resolves.toEqual(result)
await expect(joinedScan).resolves.toEqual(result)
unsubscribe()
expect(scan).toHaveBeenCalledTimes(1)
expect(store.getState().workspaceCleanupLoading).toBe(false)
})
it('shows scanned cleanup candidates before the final broad scan resolves', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' })
const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [partialCandidate],
errors: []
})
expect(store.getState().workspaceCleanupLoading).toBe(true)
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress).toMatchObject({
scannedWorktreeCount: 1,
totalWorktreeCount: 2
})
})
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate])
pending.resolve({
scannedAt: NOW,
candidates: [partialCandidate, finalCandidate],
errors: []
})
await expect(scanPromise).resolves.toEqual({
scannedAt: NOW,
candidates: [partialCandidate, finalCandidate],
errors: []
})
expect(store.getState().workspaceCleanupLoading).toBe(false)
expect(store.getState().workspaceCleanupProgress).toMatchObject({
scannedWorktreeCount: 2,
totalWorktreeCount: 2
})
})
it('does not re-probe previously enriched rows during cumulative progress updates', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' })
const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const hasChildProcesses = vi.fn().mockResolvedValue(false)
const getForegroundProcess = vi.fn().mockResolvedValue('zsh')
;(
globalThis.window as unknown as {
api: {
pty?: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: typeof getForegroundProcess
}
}
}
).api.pty = { hasChildProcesses, getForegroundProcess }
const store = createCleanupTestStore()
store.setState({
tabsByWorktree: {
'repo1::/tmp/terminal': [
{ id: 'tab-1', title: 'zsh' }
] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
} as Partial<AppState>)
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [terminalCandidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
})
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 2,
totalWorktreeCount: 2,
candidates: [laterCandidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
})
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2)
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
pending.resolve({
scannedAt: NOW,
candidates: [terminalCandidate, laterCandidate],
errors: []
})
await scanPromise
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
})
it('updates count-only append progress without replacing existing candidate rows', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const candidate = makeCandidate({ worktreeId: 'repo1::/tmp/alpha' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [candidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
})
const candidatesAfterFirstAppend = store.getState().workspaceCleanupScan?.candidates
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 2,
totalWorktreeCount: 2,
candidates: [],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
})
expect(store.getState().workspaceCleanupScan?.candidates).toBe(candidatesAfterFirstAppend)
pending.resolve({
scannedAt: NOW,
candidates: [candidate],
errors: []
})
await scanPromise
})
it('keeps append progress rows when terminal enrichment resolves out of order', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
const terminalProbe = deferred<boolean>()
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<AppState>)
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<WorkspaceCleanupScanResult>()
const terminalProbe = deferred<boolean>()
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<AppState>)
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<WorkspaceCleanupScanResult>()
const secondPending = deferred<WorkspaceCleanupScanResult>()
const terminalProbe = deferred<boolean>()
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<AppState>)
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<WorkspaceCleanupScanResult>()
const secondBroadScan = deferred<WorkspaceCleanupScanResult>()
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<AppState>)
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<WorkspaceCleanupScanResult>()
const secondPending = deferred<WorkspaceCleanupScanResult>()
const firstResult = {
scannedAt: NOW,
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/first' })],
errors: []
}
const secondResult = {
scannedAt: NOW + 1,
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/second' })],
errors: []
}
const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) =>
args?.skipGitWorktreeIds?.includes('repo1::/tmp/first')
? firstPending.promise
: secondPending.promise
)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const first = store
.getState()
.scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/first'] })
const second = store
.getState()
.scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/second'] })
expect(scan).toHaveBeenCalledTimes(2)
secondPending.resolve(secondResult)
await second
expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult)
firstPending.resolve(firstResult)
await expect(Promise.all([first, second])).resolves.toEqual([firstResult, secondResult])
expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult)
})
it('keeps stale cleanup results visible after a broad refresh failure', async () => {
const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
const scan = vi
.fn()
.mockResolvedValueOnce(previous)
.mockRejectedValueOnce(new Error('scan failed'))
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
await store.getState().scanWorkspaceCleanup()
await expect(store.getState().scanWorkspaceCleanup()).rejects.toThrow('scan failed')
expect(store.getState().workspaceCleanupScan).toMatchObject(previous)
expect(store.getState().workspaceCleanupError).toBe('scan failed')
expect(store.getState().workspaceCleanupLoading).toBe(false)
})
it('keeps focused cleanup preflight scans separate from broad scans', async () => {
const broad = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn((args?: { worktreeId?: string }) => {
if (args?.worktreeId) {
return Promise.resolve({
scannedAt: NOW + 1,
candidates: [makeCandidate({ worktreeId: args.worktreeId })],
errors: []
} satisfies WorkspaceCleanupScanResult)
}
return broad.promise
})
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const broadScan = store.getState().scanWorkspaceCleanup()
const focusedScan = await store.getState().scanWorkspaceCleanup({ worktreeId: WORKTREE_ID })
expect(scan).toHaveBeenCalledTimes(2)
expect(focusedScan.candidates[0]?.worktreeId).toBe(WORKTREE_ID)
expect(store.getState().workspaceCleanupScan).toBeNull()
broad.resolve({ scannedAt: NOW, candidates: [], errors: [] })
await broadScan
expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW)
})
})
@@ -0,0 +1,110 @@
import { create } from 'zustand'
import { vi } from 'vitest'
import type { AppState } from '../types'
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
import { createWorkspaceCleanupSlice } from './workspace-cleanup'
export const WORKTREE_ID = 'repo1::/tmp/old-workspace'
export const NOW = 1_700_000_000_000
export function makeCandidate(
overrides: Partial<WorkspaceCleanupCandidate> = {}
): WorkspaceCleanupCandidate {
return {
worktreeId: WORKTREE_ID,
repoId: 'repo1',
repoName: 'Repo 1',
connectionId: null,
displayName: 'old-workspace',
branch: 'old-workspace',
path: '/tmp/old-workspace',
tier: 'ready',
selectedByDefault: true,
reasons: ['idle-clean'],
blockers: [],
lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000,
localContext: {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: 0,
newestDiffCommentAt: null,
retainedDoneAgentCount: 0
},
git: {
clean: true,
upstreamAhead: 0,
upstreamBehind: 0,
checkedAt: NOW
},
fingerprint: 'fingerprint-1',
...overrides
}
}
export function makeState(overrides: Partial<AppState> = {}): AppState {
return {
tabsByWorktree: {},
ptyIdsByTabId: {},
openFiles: [],
editorDrafts: {},
browserTabsByWorktree: {},
retainedAgentsByPaneKey: {},
activeWorktreeId: null,
agentStatusByPaneKey: {},
runtimePaneTitlesByTabId: {},
lastVisitedAtByWorktreeId: {},
workspaceCleanupDismissals: {},
workspaceCleanupViewedCandidates: {},
...overrides
} as AppState
}
export function createCleanupTestStore(removeWorktree: ReturnType<typeof vi.fn> = vi.fn()) {
return create<AppState>()(
(...a) =>
({
tabsByWorktree: {},
ptyIdsByTabId: {},
openFiles: [],
editorDrafts: {},
browserTabsByWorktree: {},
retainedAgentsByPaneKey: {},
activeWorktreeId: null,
agentStatusByPaneKey: {},
runtimePaneTitlesByTabId: {},
lastVisitedAtByWorktreeId: {},
removeWorktree,
...createWorkspaceCleanupSlice(...a)
}) as unknown as AppState
)
}
export function installWorkspaceCleanupApi(scan: ReturnType<typeof vi.fn>) {
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan,
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
hasKillableProcesses: false
})
}
}
}
}
export function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve
reject = innerReject
})
return { promise, resolve, reject }
}
@@ -1,726 +0,0 @@
import { create } from 'zustand'
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type {
WorkspaceCleanupCandidate,
WorkspaceCleanupScanProgress,
WorkspaceCleanupScanResult
} from '../../../../shared/workspace-cleanup'
import { createWorkspaceCleanupSlice, enrichWorkspaceCleanupCandidates } from './workspace-cleanup'
const WORKTREE_ID = 'repo1::/tmp/old-workspace'
const NOW = 1_700_000_000_000
function makeCandidate(
overrides: Partial<WorkspaceCleanupCandidate> = {}
): WorkspaceCleanupCandidate {
return {
worktreeId: WORKTREE_ID,
repoId: 'repo1',
repoName: 'Repo 1',
connectionId: null,
displayName: 'old-workspace',
branch: 'old-workspace',
path: '/tmp/old-workspace',
tier: 'ready',
selectedByDefault: true,
reasons: ['idle-clean'],
blockers: [],
lastActivityAt: NOW - 30 * 24 * 60 * 60 * 1000,
localContext: {
terminalTabCount: 0,
cleanEditorTabCount: 0,
browserTabCount: 0,
diffCommentCount: 0,
newestDiffCommentAt: null,
retainedDoneAgentCount: 0
},
git: {
clean: true,
upstreamAhead: 0,
upstreamBehind: 0,
checkedAt: NOW
},
fingerprint: 'fingerprint-1',
...overrides
}
}
function makeState(overrides: Partial<AppState> = {}): AppState {
return {
tabsByWorktree: {},
ptyIdsByTabId: {},
openFiles: [],
editorDrafts: {},
browserTabsByWorktree: {},
retainedAgentsByPaneKey: {},
activeWorktreeId: null,
agentStatusByPaneKey: {},
runtimePaneTitlesByTabId: {},
lastVisitedAtByWorktreeId: {},
workspaceCleanupDismissals: {},
workspaceCleanupViewedCandidates: {},
...overrides
} as AppState
}
function createCleanupTestStore(removeWorktree = vi.fn()) {
return create<AppState>()(
(...a) =>
({
tabsByWorktree: {},
ptyIdsByTabId: {},
openFiles: [],
editorDrafts: {},
browserTabsByWorktree: {},
retainedAgentsByPaneKey: {},
activeWorktreeId: null,
agentStatusByPaneKey: {},
runtimePaneTitlesByTabId: {},
lastVisitedAtByWorktreeId: {},
removeWorktree,
...createWorkspaceCleanupSlice(...a)
}) as unknown as AppState
)
}
function installWorkspaceCleanupApi(scan: ReturnType<typeof vi.fn>) {
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan,
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
hasKillableProcesses: false
})
}
}
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (error: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve
reject = innerReject
})
return { promise, resolve, reject }
}
describe('workspace cleanup viewed rows', () => {
it('joins duplicate broad cleanup scans', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn().mockReturnValue(pending.promise)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const first = store.getState().scanWorkspaceCleanup()
const second = store.getState().scanWorkspaceCleanup()
expect(scan).toHaveBeenCalledTimes(1)
expect(store.getState().workspaceCleanupLoading).toBe(true)
const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
pending.resolve(result)
await expect(Promise.all([first, second])).resolves.toEqual([result, result])
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(1)
expect(store.getState().workspaceCleanupLoading).toBe(false)
})
it('does not leave cleanup loading stuck when a reopen joins a just-settled scan', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
const result = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
const scan = vi.fn().mockReturnValue(pending.promise)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
let joinedScan: Promise<WorkspaceCleanupScanResult> | null = null
const unsubscribe = store.subscribe((state, previousState) => {
if (
previousState.workspaceCleanupLoading &&
!state.workspaceCleanupLoading &&
joinedScan === null
) {
joinedScan = state.scanWorkspaceCleanup()
}
})
const firstScan = store.getState().scanWorkspaceCleanup()
pending.resolve(result)
await expect(firstScan).resolves.toEqual(result)
await expect(joinedScan).resolves.toEqual(result)
unsubscribe()
expect(scan).toHaveBeenCalledTimes(1)
expect(store.getState().workspaceCleanupLoading).toBe(false)
})
it('shows scanned cleanup candidates before the final broad scan resolves', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const partialCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/partial' })
const finalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/final' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [partialCandidate],
errors: []
})
expect(store.getState().workspaceCleanupLoading).toBe(true)
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress).toMatchObject({
scannedWorktreeCount: 1,
totalWorktreeCount: 2
})
})
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([partialCandidate])
pending.resolve({
scannedAt: NOW,
candidates: [partialCandidate, finalCandidate],
errors: []
})
await expect(scanPromise).resolves.toEqual({
scannedAt: NOW,
candidates: [partialCandidate, finalCandidate],
errors: []
})
expect(store.getState().workspaceCleanupLoading).toBe(false)
expect(store.getState().workspaceCleanupProgress).toMatchObject({
scannedWorktreeCount: 2,
totalWorktreeCount: 2
})
})
it('does not re-probe previously enriched rows during cumulative progress updates', async () => {
const pending = deferred<WorkspaceCleanupScanResult>()
let onProgress: ((progress: WorkspaceCleanupScanProgress) => void) | undefined
const terminalCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/terminal' })
const laterCandidate = makeCandidate({ worktreeId: 'repo1::/tmp/later' })
const scan = vi.fn((_args, progressCallback) => {
onProgress = progressCallback
return pending.promise
})
installWorkspaceCleanupApi(scan)
const hasChildProcesses = vi.fn().mockResolvedValue(false)
const getForegroundProcess = vi.fn().mockResolvedValue('zsh')
;(
globalThis.window as unknown as {
api: {
pty?: {
hasChildProcesses: typeof hasChildProcesses
getForegroundProcess: typeof getForegroundProcess
}
}
}
).api.pty = { hasChildProcesses, getForegroundProcess }
const store = createCleanupTestStore()
store.setState({
tabsByWorktree: {
'repo1::/tmp/terminal': [
{ id: 'tab-1', title: 'zsh' }
] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
} as Partial<AppState>)
const scanPromise = store.getState().scanWorkspaceCleanup()
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 1,
totalWorktreeCount: 2,
candidates: [terminalCandidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(1)
})
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
onProgress?.({
scanId: 'scan-1',
scannedAt: NOW,
scannedWorktreeCount: 2,
totalWorktreeCount: 2,
candidates: [laterCandidate],
errors: [],
candidateMode: 'append'
})
await vi.waitFor(() => {
expect(store.getState().workspaceCleanupProgress?.scannedWorktreeCount).toBe(2)
})
expect(store.getState().workspaceCleanupScan?.candidates).toHaveLength(2)
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
pending.resolve({
scannedAt: NOW,
candidates: [terminalCandidate, laterCandidate],
errors: []
})
await scanPromise
expect(hasChildProcesses).toHaveBeenCalledTimes(1)
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
})
it('does not join broad cleanup scans with different explicit args', async () => {
const firstPending = deferred<WorkspaceCleanupScanResult>()
const secondPending = deferred<WorkspaceCleanupScanResult>()
const firstResult = {
scannedAt: NOW,
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/first' })],
errors: []
}
const secondResult = {
scannedAt: NOW + 1,
candidates: [makeCandidate({ worktreeId: 'repo1::/tmp/second' })],
errors: []
}
const scan = vi.fn((args?: { skipGitWorktreeIds?: string[] }) =>
args?.skipGitWorktreeIds?.includes('repo1::/tmp/first')
? firstPending.promise
: secondPending.promise
)
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const first = store
.getState()
.scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/first'] })
const second = store
.getState()
.scanWorkspaceCleanup({ skipGitWorktreeIds: ['repo1::/tmp/second'] })
expect(scan).toHaveBeenCalledTimes(2)
secondPending.resolve(secondResult)
await second
expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult)
firstPending.resolve(firstResult)
await expect(Promise.all([first, second])).resolves.toEqual([firstResult, secondResult])
expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult)
})
it('keeps stale cleanup results visible after a broad refresh failure', async () => {
const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] }
const scan = vi
.fn()
.mockResolvedValueOnce(previous)
.mockRejectedValueOnce(new Error('scan failed'))
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
await store.getState().scanWorkspaceCleanup()
await expect(store.getState().scanWorkspaceCleanup()).rejects.toThrow('scan failed')
expect(store.getState().workspaceCleanupScan).toMatchObject(previous)
expect(store.getState().workspaceCleanupError).toBe('scan failed')
expect(store.getState().workspaceCleanupLoading).toBe(false)
})
it('keeps focused cleanup preflight scans separate from broad scans', async () => {
const broad = deferred<WorkspaceCleanupScanResult>()
const scan = vi.fn((args?: { worktreeId?: string }) => {
if (args?.worktreeId) {
return Promise.resolve({
scannedAt: NOW + 1,
candidates: [makeCandidate({ worktreeId: args.worktreeId })],
errors: []
} satisfies WorkspaceCleanupScanResult)
}
return broad.promise
})
installWorkspaceCleanupApi(scan)
const store = createCleanupTestStore()
const broadScan = store.getState().scanWorkspaceCleanup()
const focusedScan = await store.getState().scanWorkspaceCleanup({ worktreeId: WORKTREE_ID })
expect(scan).toHaveBeenCalledTimes(2)
expect(focusedScan.candidates[0]?.worktreeId).toBe(WORKTREE_ID)
expect(store.getState().workspaceCleanupScan).toBeNull()
broad.resolve({ scannedAt: NOW, candidates: [], errors: [] })
await broadScan
expect(store.getState().workspaceCleanupScan?.scannedAt).toBe(NOW)
})
it('preflights cleanup removals concurrently and deletes nested workspaces globally deepest first', async () => {
let activePreflights = 0
let maxActivePreflights = 0
let activeDeletes = 0
let maxActiveDeletes = 0
const deleteOrder: string[] = []
const candidates = [
makeCandidate({
worktreeId: 'repo-a::/repo/parent',
repoId: 'repo-a',
path: '/repo/parent',
displayName: 'parent'
}),
makeCandidate({
worktreeId: 'repo-b::/repo/parent/child',
repoId: 'repo-b',
path: '/repo/parent/child',
displayName: 'child'
}),
makeCandidate({
worktreeId: 'repo-c::/other',
repoId: 'repo-c',
path: '/other',
displayName: 'other',
git: { clean: null, upstreamAhead: null, upstreamBehind: null, checkedAt: null },
blockers: ['git-status-error']
})
]
const candidateById = new Map(candidates.map((candidate) => [candidate.worktreeId, candidate]))
const scan = vi.fn(async (args?: { worktreeId?: string }) => {
activePreflights += 1
maxActivePreflights = Math.max(maxActivePreflights, activePreflights)
await new Promise((resolve) => setTimeout(resolve, 5))
activePreflights -= 1
return {
scannedAt: NOW,
candidates: args?.worktreeId ? [candidateById.get(args.worktreeId)!] : [],
errors: []
} satisfies WorkspaceCleanupScanResult
})
installWorkspaceCleanupApi(scan)
const removeWorktree = vi.fn(async (worktreeId: string) => {
deleteOrder.push(worktreeId)
activeDeletes += 1
maxActiveDeletes = Math.max(maxActiveDeletes, activeDeletes)
await new Promise((resolve) => setTimeout(resolve, 5))
activeDeletes -= 1
return { ok: true as const }
})
const store = createCleanupTestStore(removeWorktree)
store.setState({
workspaceCleanupScan: { scannedAt: NOW, candidates, errors: [] }
} as Partial<AppState>)
await expect(
store
.getState()
.removeWorkspaceCleanupCandidates(candidates.map((candidate) => candidate.worktreeId))
).resolves.toEqual({
removedIds: expect.arrayContaining(candidates.map((candidate) => candidate.worktreeId)),
failures: []
})
expect(maxActivePreflights).toBeGreaterThan(1)
expect(maxActiveDeletes).toBe(1)
expect(deleteOrder).toEqual([
'repo-b::/repo/parent/child',
'repo-a::/repo/parent',
'repo-c::/other'
])
expect(removeWorktree).toHaveBeenCalledWith('repo-c::/other', true)
expect(store.getState().workspaceCleanupScan?.candidates).toEqual([])
})
it('demotes an active suggested workspace when it was not viewed from cleanup', async () => {
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({ activeWorktreeId: WORKTREE_ID }),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.blockers).toContain('active-workspace')
})
it('keeps a viewed active workspace visible but not removable', async () => {
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
activeWorktreeId: WORKTREE_ID,
workspaceCleanupViewedCandidates: {
[WORKTREE_ID]: {
viewedAt: Date.now(),
fingerprint: 'fingerprint-1',
wasSuggested: true
}
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('active-workspace')
})
it('does not preserve the cleanup view exception after the row changes', async () => {
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate({ fingerprint: 'fingerprint-2' })],
makeState({
activeWorktreeId: WORKTREE_ID,
workspaceCleanupViewedCandidates: {
[WORKTREE_ID]: {
viewedAt: Date.now(),
fingerprint: 'fingerprint-1',
wasSuggested: true
}
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.blockers).toContain('active-workspace')
})
it('protects recently visible old workspaces with open context', async () => {
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
openFiles: [
{
id: 'file-1',
worktreeId: WORKTREE_ID,
filePath: '/tmp/old-workspace/src/app.ts',
relativePath: 'src/app.ts',
language: 'typescript',
isDirty: false
}
] as AppState['openFiles'],
lastVisitedAtByWorktreeId: {
[WORKTREE_ID]: Date.now()
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('recent-visible-context')
})
it('uses current renderer state after async delete preflight scan resolves', async () => {
let resolveScan: (value: WorkspaceCleanupScanResult) => void
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan: vi.fn(
(): Promise<WorkspaceCleanupScanResult> =>
new Promise<WorkspaceCleanupScanResult>((resolve) => {
resolveScan = resolve
})
),
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
hasKillableProcesses: false
})
}
}
}
const removal = store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])
store.setState({ activeWorktreeId: WORKTREE_ID })
resolveScan!({ scannedAt: NOW, candidates: [makeCandidate()], errors: [] })
await expect(removal).resolves.toEqual({
removedIds: [WORKTREE_ID],
failures: []
})
expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false)
})
it('defers git checks for locally active workspaces on initial scans', async () => {
const scan = vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [],
errors: []
} satisfies WorkspaceCleanupScanResult)
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan,
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
hasKillableProcesses: false
})
}
}
}
const store = createCleanupTestStore()
store.setState({
activeWorktreeId: WORKTREE_ID,
tabsByWorktree: {
'repo1::/tmp/terminal-workspace': [
{ id: 'tab-1', title: 'zsh' }
] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
} as Partial<AppState>)
await store.getState().scanWorkspaceCleanup()
expect(scan).toHaveBeenCalledWith(
{
skipGitWorktreeIds: expect.arrayContaining([WORKTREE_ID, 'repo1::/tmp/terminal-workspace'])
},
expect.any(Function)
)
})
it('does not defer git checks for focused remove preflights', async () => {
const scan = vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [makeCandidate()],
errors: []
} satisfies WorkspaceCleanupScanResult)
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan,
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
hasKillableProcesses: false
})
}
}
}
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
const store = createCleanupTestStore(removeWorktree)
store.setState({ activeWorktreeId: 'repo1::/tmp/other-workspace' } as Partial<AppState>)
await store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])
expect(scan).toHaveBeenCalledWith({ worktreeId: WORKTREE_ID })
})
it('lets explicitly selected not-suggested workspaces reach the removal path', async () => {
const scan = vi.fn().mockResolvedValue({
scannedAt: NOW,
candidates: [makeCandidate()],
errors: []
} satisfies WorkspaceCleanupScanResult)
const removeWorktree = vi.fn().mockResolvedValue({ ok: true })
;(globalThis as { window: unknown }).window = {
api: {
workspaceCleanup: {
scan,
dismiss: vi.fn().mockResolvedValue(undefined),
clearDismissals: vi.fn().mockResolvedValue(undefined),
hasKillableLocalProcesses: vi.fn().mockResolvedValue({
hasKillableProcesses: true
})
}
}
}
const store = createCleanupTestStore(removeWorktree)
store.setState({
tabsByWorktree: {
[WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
} as Partial<AppState>)
await expect(store.getState().removeWorkspaceCleanupCandidates([WORKTREE_ID])).resolves.toEqual(
{
removedIds: [WORKTREE_ID],
failures: []
}
)
expect(removeWorktree).toHaveBeenCalledWith(WORKTREE_ID, false)
})
it('protects old workspaces when an agent process is still foregrounded', async () => {
;(globalThis as { window: unknown }).window = {
api: {
pty: {
hasChildProcesses: vi.fn().mockResolvedValue(true),
getForegroundProcess: vi.fn().mockResolvedValue('codex')
}
}
}
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
tabsByWorktree: {
[WORKTREE_ID]: [{ id: 'tab-1', title: 'zsh' }] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: { 'tab-1': ['pty-1'] }
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('running-terminal')
})
it('does not let an idle title in another tab mask a running agent process', async () => {
;(globalThis as { window: unknown }).window = {
api: {
pty: {
hasChildProcesses: vi.fn(async (ptyId: string) => ptyId === 'pty-running'),
getForegroundProcess: vi.fn(async (ptyId: string) =>
ptyId === 'pty-running' ? 'codex' : 'zsh'
)
}
}
}
const [candidate] = await enrichWorkspaceCleanupCandidates(
[makeCandidate()],
makeState({
tabsByWorktree: {
[WORKTREE_ID]: [
{ id: 'tab-running', title: 'zsh' },
{ id: 'tab-idle', title: 'Codex done' }
] as AppState['tabsByWorktree'][string]
},
ptyIdsByTabId: {
'tab-running': ['pty-running'],
'tab-idle': ['pty-idle']
}
}),
{ applyDismissals: false }
)
expect(candidate.tier).toBe('protected')
expect(candidate.selectedByDefault).toBe(false)
expect(candidate.blockers).toContain('running-terminal')
})
})
@@ -35,6 +35,12 @@ export type WorkspaceCleanupRemoveResult = {
failures: WorkspaceCleanupFailure[]
}
export type WorkspaceCleanupRemoveOptions = {
// Why: rows are removed long after the confirm click; the confirm-time
// candidate records how much git risk the user actually approved.
approvedCandidates?: readonly WorkspaceCleanupCandidate[]
}
type WorkspaceCleanupViewedCandidate = {
viewedAt: number
fingerprint: string
@@ -55,7 +61,8 @@ export type WorkspaceCleanupSlice = {
) => Promise<void>
resetWorkspaceCleanupDismissals: () => Promise<void>
removeWorkspaceCleanupCandidates: (
worktreeIds: readonly string[]
worktreeIds: readonly string[],
options?: WorkspaceCleanupRemoveOptions
) => Promise<WorkspaceCleanupRemoveResult>
}
@@ -78,10 +85,23 @@ let inFlightWorkspaceCleanupScan: {
promise: Promise<WorkspaceCleanupScanResult>
} | null = null
let latestWorkspaceCleanupScanToken = 0
let finalizedWorkspaceCleanupScanToken = 0
let workspaceCleanupProgressQueue: {
scanToken: number
promise: Promise<void>
} | null = null
let workspaceCleanupEnrichmentCache: {
scanToken: number
entries: Map<string, WorkspaceCleanupEnrichmentCacheEntry>
} | null = null
// Why: cleanup progress can append thousands of rows; keep one scan-local
// index so each streamed row does not rebuild a map of every previous row.
let workspaceCleanupProgressCandidateIndex: {
scanToken: number
scanId: string
candidates: WorkspaceCleanupCandidate[]
indexesByWorktreeId: Map<string, number>
} | null = null
const SHELL_PROCESS_NAMES = new Set([
'bash',
@@ -160,10 +180,13 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
workspaceCleanupError: null
})
const scanToken = ++latestWorkspaceCleanupScanToken
finalizedWorkspaceCleanupScanToken = 0
workspaceCleanupProgressQueue = null
workspaceCleanupEnrichmentCache = { scanToken, entries: new Map() }
workspaceCleanupProgressCandidateIndex = null
const promise = (async () => {
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 +195,13 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
)
const result = { ...scan, candidates: enriched }
if (scanToken === latestWorkspaceCleanupScanToken) {
finalizedWorkspaceCleanupScanToken = scanToken
workspaceCleanupEnrichmentCache = null
workspaceCleanupProgressCandidateIndex = null
set({
workspaceCleanupScan: result,
workspaceCleanupProgress: {
scanId: get().workspaceCleanupProgress?.scanId ?? '',
scanId: get().workspaceCleanupProgress?.scanId ?? scanArgs.scanId ?? '',
scannedAt: result.scannedAt,
scannedWorktreeCount: result.candidates.length,
totalWorktreeCount: result.candidates.length,
@@ -266,14 +292,22 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
await window.api.workspaceCleanup.clearDismissals()
},
removeWorkspaceCleanupCandidates: async (worktreeIds) => {
removeWorkspaceCleanupCandidates: async (worktreeIds, options) => {
const removedIds: string[] = []
const failures: WorkspaceCleanupFailure[] = []
const approvedCandidatesByWorktreeId = new Map(
(options?.approvedCandidates ?? []).map((candidate) => [candidate.worktreeId, candidate])
)
const preflights = await mapWithConcurrency(
worktreeIds,
WORKSPACE_CLEANUP_PREFLIGHT_CONCURRENCY,
(worktreeId) => preflightWorkspaceCleanupCandidate(worktreeId, get)
(worktreeId) =>
preflightWorkspaceCleanupCandidate(
worktreeId,
get,
approvedCandidatesByWorktreeId.get(worktreeId)
)
)
const candidatesToRemove: WorkspaceCleanupCandidate[] = []
@@ -290,7 +324,10 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
for (const candidate of [...candidatesToRemove].sort((a, b) => b.path.length - a.path.length)) {
const result = await get().removeWorktree(
candidate.worktreeId,
shouldForceWorkspaceCleanupRemoval(candidate)
shouldForceWorkspaceCleanupRemoval(candidate),
// Why: cleanup reports outcomes in its own summary toasts; per-row
// preserved-branch warnings would stack one toast per removed row.
{ suppressPreservedBranchToast: true }
)
if (result.ok) {
removedIds.push(candidate.worktreeId)
@@ -304,8 +341,10 @@ export const createWorkspaceCleanupSlice: StateCreator<AppState, [], [], Workspa
}
if (removedIds.length > 0) {
invalidateWorkspaceCleanupScanProgress()
const removedIdSet = new Set(removedIds)
set((state) => ({
workspaceCleanupLoading: false,
workspaceCleanupScan: state.workspaceCleanupScan
? {
...state.workspaceCleanupScan,
@@ -327,6 +366,43 @@ function getWorkspaceCleanupScanKey(args: WorkspaceCleanupScanArgs): string {
})
}
function invalidateWorkspaceCleanupScanProgress(): void {
latestWorkspaceCleanupScanToken += 1
finalizedWorkspaceCleanupScanToken = 0
inFlightWorkspaceCleanupScan = null
workspaceCleanupProgressQueue = null
workspaceCleanupEnrichmentCache = null
workspaceCleanupProgressCandidateIndex = null
}
function enqueueWorkspaceCleanupProgress(
progress: WorkspaceCleanupScanProgress,
scanToken: number,
getState: () => AppState,
setState: (
partial: Partial<AppState> | ((state: AppState) => Partial<AppState>),
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,
@@ -336,6 +412,12 @@ async function applyWorkspaceCleanupProgress(
replace?: false
) => void
): Promise<void> {
if (
scanToken !== latestWorkspaceCleanupScanToken ||
scanToken === finalizedWorkspaceCleanupScanToken
) {
return
}
const state = getState()
const previousCandidates =
progress.candidateMode === 'append' &&
@@ -347,11 +429,23 @@ async function applyWorkspaceCleanupProgress(
state,
scanToken
)
const candidates =
progress.candidateMode === 'append'
? appendWorkspaceCleanupProgressCandidates(previousCandidates, enrichedProgressCandidates)
: enrichedProgressCandidates
if (scanToken !== latestWorkspaceCleanupScanToken) {
if (
scanToken !== latestWorkspaceCleanupScanToken ||
scanToken === finalizedWorkspaceCleanupScanToken
) {
return
}
const candidates = mergeWorkspaceCleanupProgressCandidates({
previousCandidates,
nextCandidates: enrichedProgressCandidates,
progress,
scanToken
})
if (
scanToken !== latestWorkspaceCleanupScanToken ||
scanToken === finalizedWorkspaceCleanupScanToken
) {
workspaceCleanupProgressCandidateIndex = null
return
}
setState((state) => {
@@ -387,30 +481,74 @@ async function enrichWorkspaceCleanupCandidatesForScan(
)
}
function appendWorkspaceCleanupProgressCandidates(
previousCandidates: readonly WorkspaceCleanupCandidate[],
function mergeWorkspaceCleanupProgressCandidates({
previousCandidates,
nextCandidates,
progress,
scanToken
}: {
previousCandidates: readonly WorkspaceCleanupCandidate[]
nextCandidates: readonly WorkspaceCleanupCandidate[]
): WorkspaceCleanupCandidate[] {
if (nextCandidates.length === 0) {
return [...previousCandidates]
progress: WorkspaceCleanupScanProgress
scanToken: number
}): WorkspaceCleanupCandidate[] {
if (progress.candidateMode !== 'append') {
workspaceCleanupProgressCandidateIndex = null
return [...nextCandidates]
}
const indexesByWorktreeId = new Map(
previousCandidates.map((candidate, index) => [candidate.worktreeId, index])
if (nextCandidates.length === 0) {
return previousCandidates as WorkspaceCleanupCandidate[]
}
const indexCache = getWorkspaceCleanupProgressCandidateIndex(
previousCandidates,
progress.scanId,
scanToken
)
const merged = [...previousCandidates]
const merged = [...indexCache.candidates]
for (const candidate of nextCandidates) {
const existingIndex = indexesByWorktreeId.get(candidate.worktreeId)
const existingIndex = indexCache.indexesByWorktreeId.get(candidate.worktreeId)
if (existingIndex === undefined) {
indexesByWorktreeId.set(candidate.worktreeId, merged.length)
indexCache.indexesByWorktreeId.set(candidate.worktreeId, merged.length)
merged.push(candidate)
continue
}
merged[existingIndex] = candidate
}
workspaceCleanupProgressCandidateIndex = {
scanToken,
scanId: progress.scanId,
candidates: merged,
indexesByWorktreeId: indexCache.indexesByWorktreeId
}
return merged
}
function getWorkspaceCleanupProgressCandidateIndex(
candidates: readonly WorkspaceCleanupCandidate[],
scanId: string,
scanToken: number
): {
candidates: WorkspaceCleanupCandidate[]
indexesByWorktreeId: Map<string, number>
} {
if (
workspaceCleanupProgressCandidateIndex?.scanToken === scanToken &&
workspaceCleanupProgressCandidateIndex.scanId === scanId &&
workspaceCleanupProgressCandidateIndex.candidates === candidates
) {
return workspaceCleanupProgressCandidateIndex
}
return {
candidates: [...candidates],
indexesByWorktreeId: new Map(
candidates.map((candidate, index) => [candidate.worktreeId, index])
)
}
}
async function mapWithConcurrency<T, R>(
items: readonly T[],
limit: number,
@@ -681,7 +819,8 @@ function applyDismissal(
async function preflightWorkspaceCleanupCandidate(
worktreeId: string,
getState: () => AppState
getState: () => AppState,
approvedCandidate?: WorkspaceCleanupCandidate
): Promise<
| { ok: true; candidate: WorkspaceCleanupCandidate }
| { ok: false; failure: WorkspaceCleanupFailure }
@@ -715,6 +854,26 @@ async function preflightWorkspaceCleanupCandidate(
}
}
}
// Why: this row may be removed minutes after the confirm click. If it now
// needs a force removal the user never approved (new dirt, unpushed work,
// or a git error since confirmation), fail it instead of force-deleting.
if (
approvedCandidate &&
shouldForceWorkspaceCleanupRemoval(candidate) &&
!shouldForceWorkspaceCleanupRemoval(approvedCandidate)
) {
return {
ok: false,
failure: {
worktreeId,
displayName: candidate.displayName,
message: translate(
'auto.store.slices.workspace.cleanup.changedSinceConfirmation',
'Workspace changed after confirmation. Refresh to review it before removing.'
)
}
}
}
return { ok: true, candidate }
}
@@ -30,6 +30,7 @@ export { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
export type WorktreeDeleteState = {
isDeleting: boolean
phase?: 'deleting' | 'queued'
error: string | null
canForceDelete: boolean
}
@@ -181,9 +182,10 @@ 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
forceDeletePreservedBranch: (
worktreeId: string,
branchName: string,
+27 -1
View File
@@ -3151,6 +3151,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
...s.deleteStateByWorktreeId,
[worktreeId]: {
isDeleting: true,
phase: 'deleting',
error: null,
canForceDelete: false
}
@@ -3508,7 +3509,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
// 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)
})
@@ -3548,6 +3549,31 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
}
nextDeleteState[worktreeId] = {
isDeleting: true,
phase: 'deleting',
error: null,
canForceDelete: false
}
changed = true
}
return changed ? { deleteStateByWorktreeId: nextDeleteState } : {}
})
},
markWorktreesQueuedForDeletion: (worktreeIds) => {
if (worktreeIds.length === 0) {
return
}
set((s) => {
const nextDeleteState = { ...s.deleteStateByWorktreeId }
let changed = false
for (const worktreeId of new Set(worktreeIds)) {
const current = nextDeleteState[worktreeId]
if (current?.isDeleting && current.error === null && !current.canForceDelete) {
continue
}
nextDeleteState[worktreeId] = {
isDeleting: true,
phase: 'queued',
error: null,
canForceDelete: false
}