diff --git a/src/main/ipc/workspace-cleanup-broad-scan.test.ts b/src/main/ipc/workspace-cleanup-broad-scan.test.ts index e7ec27412db..0f663f4c58e 100644 --- a/src/main/ipc/workspace-cleanup-broad-scan.test.ts +++ b/src/main/ipc/workspace-cleanup-broad-scan.test.ts @@ -193,19 +193,48 @@ describe('workspace cleanup broad scan opt-in', () => { }) it('reports folder workspaces with a folder-repo blocker', async () => { - const result = await scanWorkspaceCleanup(makeStore([FOLDER_REPO]), { + const instanceId = `${FOLDER_REPO.id}::${FOLDER_REPO.path}::workspace:11111111-2222-4333-8444-555555555555` + const instanceMeta = makeWorktreeMeta({ + displayName: 'Folder session', + lastActivityAt: NOW - 2 * DAY_MS + }) + const result = await scanWorkspaceCleanup( + makeStore([FOLDER_REPO], { [instanceId]: instanceMeta }), + { includeAllWorkspaces: true } + ) + + expect(result.candidates).toEqual([ + expect.objectContaining({ + worktreeId: 'repo-folder::/folder-workspace', + tier: 'protected', + selectedByDefault: false, + blockers: expect.arrayContaining(['folder-repo', 'main-worktree']) + }), + expect.objectContaining({ + worktreeId: instanceId, + displayName: 'Folder session', + path: FOLDER_REPO.path, + tier: 'protected', + selectedByDefault: false, + blockers: ['folder-repo'] + }) + ]) + }) + + it('does not mark connected SSH folder workspaces as disconnected', async () => { + const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' } + getSshGitProviderMock.mockReturnValue({}) + + const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo]), { includeAllWorkspaces: true }) expect(result.candidates).toHaveLength(1) expect(result.candidates[0]).toMatchObject({ worktreeId: 'repo-folder::/folder-workspace', - tier: 'protected', - selectedByDefault: false + connectionId: 'ssh-1', + blockers: ['main-worktree', 'folder-repo'] }) - expect(result.candidates[0]?.blockers).toEqual( - expect.arrayContaining(['folder-repo', 'main-worktree']) - ) }) it('defers git evidence for rows that no cleanup decision can use', async () => { @@ -258,6 +287,27 @@ describe('workspace cleanup broad scan opt-in', () => { }) }) + it('uses the backing path for disconnected SSH folder instances', async () => { + const sshFolderRepo: Repo = { ...FOLDER_REPO, connectionId: 'ssh-1' } + const instanceId = `${sshFolderRepo.id}::${sshFolderRepo.path}::workspace:11111111-2222-4333-8444-555555555555` + const allMeta = { + [instanceId]: makeWorktreeMeta({ displayName: 'Remote folder session' }) + } + + const result = await scanWorkspaceCleanup(makeStore([sshFolderRepo], allMeta), { + includeAllWorkspaces: true + }) + + expect(result.candidates).toEqual([ + expect.objectContaining({ + worktreeId: instanceId, + path: sshFolderRepo.path, + branch: 'folder-workspace', + blockers: ['ssh-disconnected'] + }) + ]) + }) + it('streams every row through scan progress on an opt-in scan', async () => { const progress: { scannedWorktreeCount: number; totalWorktreeCount: number }[] = [] diff --git a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts index 9e2ce2eef47..640e2e4e513 100644 --- a/src/main/ipc/workspace-cleanup-disconnected-ssh.ts +++ b/src/main/ipc/workspace-cleanup-disconnected-ssh.ts @@ -6,7 +6,7 @@ import { createWorkspaceCleanupFingerprint, type WorkspaceCleanupCandidate } from '../../shared/workspace-cleanup' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { getNewestWorkspaceCleanupDiffCommentAt, getWorkspaceCleanupInactivityReasonsForWorkspace, @@ -52,7 +52,7 @@ function createDisconnectedSshCandidate( worktreeId: string, meta: WorktreeMeta ): WorkspaceCleanupCandidate { - const parsed = splitWorktreeId(worktreeId) + const parsed = splitWorktreeIdForFilesystem(worktreeId) const path = parsed?.worktreePath ?? worktreeId const reasons = getWorkspaceCleanupInactivityReasonsForWorkspace(meta, scannedAt) return applyWorkspaceCleanupPolicy({ diff --git a/src/main/ipc/workspace-cleanup-folder-workspaces.ts b/src/main/ipc/workspace-cleanup-folder-workspaces.ts new file mode 100644 index 00000000000..597c70db44b --- /dev/null +++ b/src/main/ipc/workspace-cleanup-folder-workspaces.ts @@ -0,0 +1,22 @@ +import type { Store } from '../persistence' +import { createFolderWorktree } from '../repo-worktrees' +import type { Repo, Worktree } from '../../shared/types' +import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR } from '../../shared/worktree-id' +import { mergeWorktree } from './worktree-logic' + +export function listWorkspaceCleanupFolderWorkspaces(store: Store, repo: Repo): Worktree[] { + const rootId = `${repo.id}::${repo.path}` + const instancePrefix = `${rootId}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}` + const allMeta = store.getAllWorktreeMeta() + const worktreeIds = [ + rootId, + ...Object.keys(allMeta).filter((worktreeId) => worktreeId.startsWith(instancePrefix)) + ] + const folderWorktree = createFolderWorktree(repo) + + return worktreeIds.map((worktreeId) => ({ + ...mergeWorktree(repo.id, folderWorktree, store.getWorktreeMeta(worktreeId), repo.displayName), + id: worktreeId, + isMainWorktree: worktreeId === rootId + })) +} diff --git a/src/main/ipc/workspace-cleanup-scan.ts b/src/main/ipc/workspace-cleanup-scan.ts index efa5b79fa52..4af4ea36eb4 100644 --- a/src/main/ipc/workspace-cleanup-scan.ts +++ b/src/main/ipc/workspace-cleanup-scan.ts @@ -33,6 +33,7 @@ import { withWorkspaceCleanupTimeout } from './workspace-cleanup-scan-primitives' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' +import { listWorkspaceCleanupFolderWorkspaces } from './workspace-cleanup-folder-workspaces' const WORKTREE_SCAN_CONCURRENCY = 3 @@ -142,11 +143,14 @@ async function scanRepoWorkspaces( return { scannedAt, candidates, errors: [] } } - const mergedWorktrees = gitWorktrees.map((gitWorktree) => { - const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = store.getWorktreeMeta(worktreeId) - return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) - }) + const mergedWorktrees = + repoIsFolder && includeAllWorkspaces + ? listWorkspaceCleanupFolderWorkspaces(store, repo) + : gitWorktrees.map((gitWorktree) => { + const worktreeId = `${repo.id}::${gitWorktree.path}` + const meta = store.getWorktreeMeta(worktreeId) + return mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) + }) // Why: with includeAllWorkspaces the browser shows every workspace and lets // filters narrow it; an age threshold here would hide rows from all views. const candidateWorktrees = targetWorktreeId @@ -227,7 +231,10 @@ async function listCleanupGitWorktrees( repoIsFolder: boolean ): Promise<{ provider: IGitProvider | null; gitWorktrees: GitWorktreeInfo[] }> { if (repoIsFolder) { - return { provider: null, gitWorktrees: [createFolderWorktree(repo)] } + return { + provider: repo.connectionId ? (getSshGitProvider(repo.connectionId) ?? null) : null, + gitWorktrees: [createFolderWorktree(repo)] + } } if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) ?? null diff --git a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts index f38a10f86e5..2ac29470f69 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-scan-progress.test.ts @@ -552,6 +552,24 @@ describe('workspace cleanup scan progress', () => { expect(store.getState().workspaceCleanupScan).toMatchObject(secondResult) }) + it('does not join legacy and full-workspace broad scans', async () => { + const legacyPending = deferred() + const fullPending = deferred() + const scan = vi.fn((args?: { includeAllWorkspaces?: boolean }) => + args?.includeAllWorkspaces ? fullPending.promise : legacyPending.promise + ) + installWorkspaceCleanupApi(scan) + const store = createCleanupTestStore() + + const legacy = store.getState().scanWorkspaceCleanup() + const full = store.getState().scanWorkspaceCleanup({ includeAllWorkspaces: true }) + + expect(scan).toHaveBeenCalledTimes(2) + fullPending.resolve({ scannedAt: NOW, candidates: [], errors: [] }) + legacyPending.resolve({ scannedAt: NOW - 1, candidates: [], errors: [] }) + await expect(Promise.all([legacy, full])).resolves.toHaveLength(2) + }) + it('keeps stale cleanup results visible after a broad refresh failure', async () => { const previous = { scannedAt: NOW, candidates: [makeCandidate()], errors: [] } const scan = vi diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index bffe5fb93d4..c26df0b635f 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -367,6 +367,7 @@ export const createWorkspaceCleanupSlice: StateCreator