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