diff --git a/src/main/ipc/workspace-space.test.ts b/src/main/ipc/workspace-space.test.ts index 24f590786d8..437dd1c0cb0 100644 --- a/src/main/ipc/workspace-space.test.ts +++ b/src/main/ipc/workspace-space.test.ts @@ -1,15 +1,29 @@ import { describe, expect, it, vi } from 'vitest' -import type { WorkspaceSpaceAnalysis } from '../../shared/workspace-space-types' +import type { + WorkspaceSpaceAnalysis, + WorkspaceSpaceAnalyzeResult, + WorkspaceSpaceScanProgress +} from '../../shared/workspace-space-types' import type { Store } from '../persistence' -const { handlers, analyzeWorkspaceSpaceMock, removeHandlerMock, handleMock } = vi.hoisted(() => ({ - handlers: new Map Promise>(), - analyzeWorkspaceSpaceMock: vi.fn(), - removeHandlerMock: vi.fn(), - handleMock: vi.fn((channel: string, handler: () => Promise) => { - handlers.set(channel, handler) - }) -})) +const { + handlers, + analyzeWorkspaceSpaceMock, + removeHandlerMock, + handleMock, + WorkspaceSpaceScanCancelledErrorMock +} = vi.hoisted(() => { + const handlers = new Map Promise>() + return { + handlers, + analyzeWorkspaceSpaceMock: vi.fn(), + removeHandlerMock: vi.fn(), + handleMock: vi.fn((channel: string, handler: (...args: unknown[]) => Promise) => { + handlers.set(channel, handler) + }), + WorkspaceSpaceScanCancelledErrorMock: class WorkspaceSpaceScanCancelledError extends Error {} + } +}) vi.mock('electron', () => ({ ipcMain: { @@ -19,6 +33,7 @@ vi.mock('electron', () => ({ })) vi.mock('../workspace-space-analysis', () => ({ + WorkspaceSpaceScanCancelledError: WorkspaceSpaceScanCancelledErrorMock, analyzeWorkspaceSpace: analyzeWorkspaceSpaceMock })) @@ -37,6 +52,19 @@ function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis { } } +function createAnalyzeResult(scannedAt: number): WorkspaceSpaceAnalyzeResult { + return { ok: true, analysis: createAnalysis(scannedAt) } +} + +function createEvent() { + return { + sender: { + isDestroyed: vi.fn(() => false), + send: vi.fn() + } + } +} + describe('registerWorkspaceSpaceHandlers', () => { it('shares an in-flight analysis request', async () => { const store = {} as Store @@ -53,17 +81,84 @@ describe('registerWorkspaceSpaceHandlers', () => { const handler = handlers.get('workspaceSpace:analyze') expect(handler).toBeDefined() - const first = handler!() - const duplicate = handler!() + const firstEvent = createEvent() + const secondEvent = createEvent() + const first = handler!(firstEvent) + const duplicate = handler!(secondEvent) expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledTimes(1) - expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledWith(store) + expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledWith( + store, + expect.objectContaining({ + scanId: expect.any(String), + signal: expect.any(AbortSignal), + onProgress: expect.any(Function) + }) + ) const firstResult = createAnalysis(1) resolveFirstScan(firstResult) - await expect(first).resolves.toBe(firstResult) - await expect(duplicate).resolves.toBe(firstResult) + await expect(first).resolves.toEqual({ ok: true, analysis: firstResult }) + await expect(duplicate).resolves.toEqual({ ok: true, analysis: firstResult }) - await expect(handler!()).resolves.toEqual(createAnalysis(2)) + await expect(handler!(createEvent())).resolves.toEqual(createAnalyzeResult(2)) expect(analyzeWorkspaceSpaceMock).toHaveBeenCalledTimes(2) }) + + it('forwards scan progress to the requesting renderer', async () => { + const store = {} as Store + let onProgress: ((progress: WorkspaceSpaceScanProgress) => void) | undefined + analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => { + onProgress = options.onProgress + return Promise.resolve(createAnalysis(1)) + }) + + registerWorkspaceSpaceHandlers(store) + const event = createEvent() + const handler = handlers.get('workspaceSpace:analyze') + const promise = handler!(event) + const progress: WorkspaceSpaceScanProgress = { + scanId: 'scan-1', + state: 'running', + startedAt: 1, + updatedAt: 1, + totalRepoCount: 1, + scannedRepoCount: 0, + totalWorktreeCount: 2, + scannedWorktreeCount: 1, + currentRepoDisplayName: 'orca', + currentWorktreeDisplayName: 'feature' + } + onProgress?.(progress) + await promise + + expect(event.sender.send).toHaveBeenCalledWith('workspaceSpace:progress', progress) + }) + + it('cancels the in-flight scan', async () => { + const store = {} as Store + let signal: AbortSignal | undefined + analyzeWorkspaceSpaceMock.mockImplementationOnce((_store, options) => { + signal = options.signal + return new Promise(() => {}) + }) + + registerWorkspaceSpaceHandlers(store) + const analyzeHandler = handlers.get('workspaceSpace:analyze') + const cancelHandler = handlers.get('workspaceSpace:cancel') + void analyzeHandler!(createEvent()) + + await expect(cancelHandler!()).resolves.toBe(true) + expect(signal?.aborted).toBe(true) + await expect(cancelHandler!()).resolves.toBe(false) + }) + + it('returns a normal cancelled result instead of rejecting expected cancellation', async () => { + const store = {} as Store + analyzeWorkspaceSpaceMock.mockRejectedValueOnce(new WorkspaceSpaceScanCancelledErrorMock()) + + registerWorkspaceSpaceHandlers(store) + const analyzeHandler = handlers.get('workspaceSpace:analyze') + + await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: false, cancelled: true }) + }) }) diff --git a/src/main/ipc/workspace-space.ts b/src/main/ipc/workspace-space.ts index 8302503bdf8..d4449f3db52 100644 --- a/src/main/ipc/workspace-space.ts +++ b/src/main/ipc/workspace-space.ts @@ -1,19 +1,107 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' -import type { WorkspaceSpaceAnalysis } from '../../shared/workspace-space-types' -import { analyzeWorkspaceSpace } from '../workspace-space-analysis' +import type { + WorkspaceSpaceAnalyzeResult, + WorkspaceSpaceScanProgress +} from '../../shared/workspace-space-types' +import { + analyzeWorkspaceSpace, + WorkspaceSpaceScanCancelledError +} from '../workspace-space-analysis' + +const PROGRESS_EMIT_INTERVAL_MS = 100 + +type InFlightWorkspaceSpaceScan = { + scanId: string + controller: AbortController + progress: WorkspaceSpaceScanProgress + promise: Promise +} export function registerWorkspaceSpaceHandlers(store: Store): void { - let inFlightScan: Promise | null = null + let inFlightScan: InFlightWorkspaceSpaceScan | null = null + ipcMain.removeHandler('workspaceSpace:cancel') ipcMain.removeHandler('workspaceSpace:analyze') - ipcMain.handle('workspaceSpace:analyze', async (): Promise => { + ipcMain.handle('workspaceSpace:analyze', async (event): Promise => { if (!inFlightScan) { + const controller = new AbortController() + const scanId = `${Date.now()}-${Math.random().toString(36).slice(2)}` + let latestProgress: WorkspaceSpaceScanProgress = { + scanId, + state: 'running', + startedAt: Date.now(), + updatedAt: Date.now(), + totalRepoCount: 0, + scannedRepoCount: 0, + totalWorktreeCount: 0, + scannedWorktreeCount: 0, + currentRepoDisplayName: null, + currentWorktreeDisplayName: null + } + let lastProgressSentAt = 0 + const sendProgress = (progress: WorkspaceSpaceScanProgress): void => { + // Why: large fleets can report one progress event per worktree; keep + // the UI responsive without repainting the full Space page for each row. + const now = Date.now() + const isFirstProgress = lastProgressSentAt === 0 + const isTerminalProgress = + progress.state !== 'running' || + (progress.totalWorktreeCount > 0 && + progress.scannedWorktreeCount >= progress.totalWorktreeCount) + if ( + !isFirstProgress && + !isTerminalProgress && + now - lastProgressSentAt < PROGRESS_EMIT_INTERVAL_MS + ) { + return + } + lastProgressSentAt = now + if (!event.sender.isDestroyed()) { + event.sender.send('workspaceSpace:progress', progress) + } + } // Why: large worktree fleets require real disk traversal; duplicate // requests should share that IO instead of starting competing scans. - inFlightScan = analyzeWorkspaceSpace(store).finally(() => { - inFlightScan = null + const scan: InFlightWorkspaceSpaceScan = { + scanId, + controller, + progress: latestProgress, + promise: Promise.resolve(null as never) + } + inFlightScan = scan + scan.promise = analyzeWorkspaceSpace(store, { + scanId, + signal: controller.signal, + onProgress: (progress) => { + latestProgress = progress + scan.progress = progress + sendProgress(progress) + } }) + .then((analysis): WorkspaceSpaceAnalyzeResult => ({ ok: true, analysis })) + .catch((error: unknown): WorkspaceSpaceAnalyzeResult => { + if (error instanceof WorkspaceSpaceScanCancelledError) { + return { ok: false, cancelled: true } + } + throw error + }) + .finally(() => { + inFlightScan = null + }) } - return inFlightScan + return inFlightScan.promise + }) + + ipcMain.handle('workspaceSpace:cancel', async (): Promise => { + if (!inFlightScan || inFlightScan.controller.signal.aborted) { + return false + } + inFlightScan.controller.abort() + inFlightScan.progress = { + ...inFlightScan.progress, + state: 'cancelling', + updatedAt: Date.now() + } + return true }) } diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 25ff76c4dba..4a0de9b6e97 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -308,6 +308,25 @@ describe('registerWorktreeHandlers', () => { registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never) }) + function mockKnownFeatureWorktree(path = '/workspace/feature-wt'): void { + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/repo', + head: 'main', + branch: 'main', + isBare: false, + isMainWorktree: true + }, + { + path, + head: 'feature', + branch: 'feature', + isBare: false, + isMainWorktree: false + } + ]) + } + it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => { // Why: new-workspace flow should silently try improve-dashboard-2, -3, ... // rather than failing and forcing the user back to the name picker. @@ -1218,6 +1237,7 @@ describe('registerWorktreeHandlers', () => { }) it('prunes git worktree tracking when removing an orphaned worktree', async () => { + mockKnownFeatureWorktree() const orphanError = Object.assign(new Error('git worktree remove failed'), { stderr: "fatal: '/workspace/feature-wt' is not a working tree" }) @@ -1241,7 +1261,7 @@ describe('registerWorktreeHandlers', () => { }) it('runs the archive hook on remove when skipArchive is not set', async () => { - listWorktreesMock.mockResolvedValue([]) + mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) getEffectiveHooksMock.mockReturnValue({ scripts: { @@ -1267,7 +1287,7 @@ describe('registerWorktreeHandlers', () => { }) it('skips the archive hook on remove when skipArchive is true', async () => { - listWorktreesMock.mockResolvedValue([]) + mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue(undefined) getEffectiveHooksMock.mockReturnValue({ scripts: { @@ -1289,8 +1309,43 @@ describe('registerWorktreeHandlers', () => { ) }) + it('rejects unregistered delete paths before teardown, hooks, or git removal', async () => { + mockKnownFeatureWorktree('/workspace/real-feature') + getEffectiveHooksMock.mockReturnValue({ + scripts: { + archive: 'echo archived' + } + }) + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/not-a-worktree' + }) + ).rejects.toThrow('Refusing to delete unregistered worktree path') + + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(runHookMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + + it('rejects the main worktree before teardown, hooks, or git removal', async () => { + mockKnownFeatureWorktree() + + await expect( + handlers['worktrees:remove'](null, { + worktreeId: 'repo-1::/workspace/repo' + }) + ).rejects.toThrow('Refusing to delete protected worktree path') + + expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled() + expect(runHookMock).not.toHaveBeenCalled() + expect(removeWorktreeMock).not.toHaveBeenCalled() + expect(store.removeWorktreeMeta).not.toHaveBeenCalled() + }) + it('IPC-initiated delete kills PTYs BEFORE git-level removal (design §4.3)', async () => { - listWorktreesMock.mockResolvedValue([]) + mockKnownFeatureWorktree() getEffectiveHooksMock.mockReturnValue(null) const callOrder: string[] = [] killAllProcessesForWorktreeMock.mockImplementation(async () => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index eef52f3d289..8bb2b01494a 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -13,7 +13,7 @@ import type { Repo, WorktreeMeta } from '../../shared/types' -import { removeWorktree } from '../git/worktree' +import { listWorktrees as listGitWorktrees, removeWorktree } from '../git/worktree' import { gitExecFileAsync } from '../git/runner' import { getDefaultRemote } from '../git/repo' import { getPullRequestPushTarget, getWorkItem } from '../github/client' @@ -51,6 +51,10 @@ import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' import { classifyWorkspaceCreateError } from './workspace-create-error-classifier' +import { + canSafelyRemoveOrphanedWorktreeDirectory, + getRegisteredDeletableWorktree +} from '../worktree-removal-safety' // Why: worktrees discovered on disk (not created via Orca's UI) have no // persisted WorktreeMeta, so mergeWorktree falls back to `lastActivityAt: 0`. @@ -517,6 +521,21 @@ export function registerWorktreeHandlers( throw new Error('Folder mode does not support deleting worktrees.') } + // Why: the renderer-supplied worktreeId contains a filesystem path. + // Re-derive the canonical path from git before any destructive action. + const provider = repo.connectionId ? getSshGitProvider(repo.connectionId) : null + if (repo.connectionId && !provider) { + throw new Error(`No git provider for connection "${repo.connectionId}"`) + } + const registeredWorktrees = repo.connectionId + ? await provider!.listWorktrees(repo.path) + : await listGitWorktrees(repo.path) + const canonicalWorktreePath = getRegisteredDeletableWorktree( + repo.path, + worktreePath, + registeredWorktrees + ).path + // Why: kill every PTY belonging to this worktree BEFORE git-level // removal. The renderer pre-kills via shutdownWorktreeTerminals, but // defensive teardown here protects against: (a) a future renderer bug, @@ -543,11 +562,7 @@ export function registerWorktreeHandlers( } if (repo.connectionId) { - const provider = getSshGitProvider(repo.connectionId) - if (!provider) { - throw new Error(`No git provider for connection "${repo.connectionId}"`) - } - await provider.removeWorktree(worktreePath, args.force) + await provider!.removeWorktree(canonicalWorktreePath, args.force) runtime.clearOptimisticReconcileToken(args.worktreeId) await runtime.unlinkNotesWorktree(repoId, args.worktreeId) store.removeWorktreeMeta(args.worktreeId) @@ -559,9 +574,9 @@ export function registerWorktreeHandlers( // Run archive hook before removal const hooks = getEffectiveHooks(repo) if (hooks?.scripts.archive && !args.skipArchive) { - const result = await runHook('archive', worktreePath, repo) + const result = await runHook('archive', canonicalWorktreePath, repo) if (!result.success) { - console.error(`[hooks] archive hook failed for ${worktreePath}:`, result.output) + console.error(`[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output) } } @@ -571,16 +586,24 @@ export function registerWorktreeHandlers( // first so the normal delete path keeps working — otherwise every // deletion would require the Force Delete toast once the feature is on. if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { - await removeWorktreeSymlinks(worktreePath, repo.symlinkPaths) + await removeWorktreeSymlinks(canonicalWorktreePath, repo.symlinkPaths) } try { - await removeWorktree(repo.path, worktreePath, args.force ?? false) + await removeWorktree(repo.path, canonicalWorktreePath, args.force ?? false) } catch (error) { // If git no longer tracks this worktree, clean up the directory and metadata if (isOrphanedWorktreeError(error)) { - console.warn(`[worktrees] Orphaned worktree detected at ${worktreePath}, cleaning up`) - await rm(worktreePath, { recursive: true, force: true }).catch(() => {}) + console.warn( + `[worktrees] Orphaned worktree detected at ${canonicalWorktreePath}, cleaning up` + ) + if (await canSafelyRemoveOrphanedWorktreeDirectory(canonicalWorktreePath, repo.path)) { + await rm(canonicalWorktreePath, { recursive: true, force: true }).catch(() => {}) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${canonicalWorktreePath}` + ) + } // Why: `git worktree remove` failed, so git's internal worktree tracking // (`.git/worktrees/`) is still intact. Without pruning, `git worktree // list` continues to show the stale entry and the branch it had checked out @@ -594,7 +617,9 @@ export function registerWorktreeHandlers( notifyWorktreesChanged(mainWindow, repoId) return } - throw new Error(formatWorktreeRemovalError(error, worktreePath, args.force ?? false)) + throw new Error( + formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false) + ) } runtime.clearOptimisticReconcileToken(args.worktreeId) await runtime.unlinkNotesWorktree(repoId, args.worktreeId) diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index 8fbf9551c00..037c7047756 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -162,6 +162,27 @@ describe('SshFilesystemProvider', () => { }) }) + it('scanWorkspaceSpace sends an abortable bulk scan request', async () => { + const result = { + sizeBytes: 1024, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + const controller = new AbortController() + mux.request.mockResolvedValue(result) + + await expect( + provider.scanWorkspaceSpace('/home/user/project', { signal: controller.signal }) + ).resolves.toBe(result) + expect(mux.request).toHaveBeenCalledWith( + 'fs.workspaceSpaceScan', + { rootPath: '/home/user/project' }, + { signal: controller.signal, timeoutMs: 130000 } + ) + }) + it('deletePath sends fs.deletePath request', async () => { await provider.deletePath('/home/user/file.txt') expect(mux.request).toHaveBeenCalledWith('fs.deletePath', { targetPath: '/home/user/file.txt' }) diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts index b33c0dbe08b..cc334a3cf83 100644 --- a/src/main/providers/ssh-filesystem-provider.ts +++ b/src/main/providers/ssh-filesystem-provider.ts @@ -4,6 +4,7 @@ import { uploadBuffer } from '../ssh/sftp-upload' import type { IFilesystemProvider, FileStat, FileReadResult } from './types' import type { DirEntry, FsChangeEvent, SearchOptions, SearchResult } from '../../shared/types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import type { SFTPWrapper } from 'ssh2' type SftpFactory = () => Promise @@ -12,6 +13,8 @@ type WatchRegistration = { setupPromise: Promise } +const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000 + export class SshFilesystemProvider implements IFilesystemProvider { private connectionId: string private mux: SshChannelMultiplexer @@ -122,6 +125,17 @@ export class SshFilesystemProvider implements IFilesystemProvider { return (await this.mux.request('fs.stat', { filePath })) as FileStat } + async scanWorkspaceSpace( + rootPath: string, + options?: { signal?: AbortSignal } + ): Promise { + return (await this.mux.request( + 'fs.workspaceSpaceScan', + { rootPath }, + { signal: options?.signal, timeoutMs: WORKSPACE_SPACE_SCAN_TIMEOUT_MS } + )) as WorkspaceSpaceDirectoryScanResult + } + async deletePath(targetPath: string, recursive?: boolean): Promise { await this.mux.request('fs.deletePath', { targetPath, recursive }) } diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 68346c81e0f..5643c3c2d29 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -200,8 +200,13 @@ describe('SshGitProvider', () => { ] mux.request.mockResolvedValue(worktrees) - const result = await provider.listWorktrees('/home/user/repo') - expect(mux.request).toHaveBeenCalledWith('git.listWorktrees', { repoPath: '/home/user/repo' }) + const controller = new AbortController() + const result = await provider.listWorktrees('/home/user/repo', { signal: controller.signal }) + expect(mux.request).toHaveBeenCalledWith( + 'git.listWorktrees', + { repoPath: '/home/user/repo' }, + { signal: controller.signal } + ) expect(result).toEqual(worktrees) }) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index e93ad886adf..45dec93c79b 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -123,10 +123,17 @@ export class SshGitProvider implements IGitProvider { })) as GitDiffResult[] } - async listWorktrees(repoPath: string): Promise { - return (await this.mux.request('git.listWorktrees', { - repoPath - })) as GitWorktreeInfo[] + async listWorktrees( + repoPath: string, + options?: { signal?: AbortSignal } + ): Promise { + return (await this.mux.request( + 'git.listWorktrees', + { + repoPath + }, + { signal: options?.signal } + )) as GitWorktreeInfo[] } async addWorktree( diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 998c22b3f73..3e091ea1f76 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -11,6 +11,7 @@ import type { SearchOptions, SearchResult } from '../../shared/types' +import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' // ─── PTY Provider ─────────────────────────────────────────────────── @@ -130,6 +131,10 @@ export type IFilesystemProvider = { realpath(filePath: string): Promise search(opts: SearchOptions): Promise listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise + scanWorkspaceSpace?( + rootPath: string, + options?: { signal?: AbortSignal } + ): Promise watch(rootPath: string, callback: (events: FsChangeEvent[]) => void): Promise<() => void> } @@ -161,7 +166,7 @@ export type IGitProvider = { baseRef: string, options?: { includePatch?: boolean; filePath?: string; oldPath?: string } ): Promise - listWorktrees(repoPath: string): Promise + listWorktrees(repoPath: string, options?: { signal?: AbortSignal }): Promise addWorktree( repoPath: string, branchName: string, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 38fa678ec92..3ee8b60e261 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -222,6 +222,7 @@ import { shouldSetDisplayName, areWorktreePathsEqual } from '../ipc/worktree-logic' +import { canSafelyRemoveOrphanedWorktreeDirectory } from '../worktree-removal-safety' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { HeadlessEmulator } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' @@ -6004,7 +6005,13 @@ export class OrcaRuntimeService { await removeWorktree(repo.path, worktree.path, force) } catch (error) { if (isOrphanedWorktreeError(error)) { - await rm(worktree.path, { recursive: true, force: true }).catch(() => {}) + if (await canSafelyRemoveOrphanedWorktreeDirectory(worktree.path, repo.path)) { + await rm(worktree.path, { recursive: true, force: true }).catch(() => {}) + } else { + console.warn( + `[worktrees] Refusing recursive cleanup for unproven worktree directory: ${worktree.path}` + ) + } // Why: `git worktree remove` failed, so git's internal worktree tracking // (`.git/worktrees/`) is still intact. Without pruning, `git worktree // list` continues to show the stale entry and the branch it had checked out diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index f8ba76209b2..7df48286090 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -123,6 +123,34 @@ describe('SshChannelMultiplexer', () => { vi.advanceTimersByTime(1_000) await expect(promise).rejects.toThrow('timed out') + const cancelPayload = JSON.parse( + transport.written + .at(-1)! + .subarray(HEADER_LENGTH, HEADER_LENGTH + transport.written.at(-1)!.readUInt32BE(9)) + .toString() + ) + expect(cancelPayload).toMatchObject({ + method: 'rpc.cancel', + params: { id: 1 } + }) + }) + + it('uses per-request timeout overrides', async () => { + const promise = mux.request('fs.workspaceSpaceScan', {}, { timeoutMs: 60_000 }) + + for (let i = 0; i < 6; i++) { + vi.advanceTimersByTime(5_000) + transport.dataCallbacks[0](encodeKeepAliveFrame(i + 1, 0)) + } + await Promise.resolve() + const requestWrites = transport.written.filter((frame) => frame[0] === MessageType.Regular) + expect(requestWrites).toHaveLength(1) + + for (let i = 6; i < 12; i++) { + vi.advanceTimersByTime(5_000) + transport.dataCallbacks[0](encodeKeepAliveFrame(i + 1, 0)) + } + await expect(promise).rejects.toThrow('timed out after 60000ms') }) it('assigns unique request IDs', async () => { diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 11a18bebe82..9d95bbd05b8 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: the SSH relay protocol state machine keeps + request, notification, keepalive, and cancellation semantics paired. */ import { FrameDecoder, MessageType, @@ -24,6 +26,7 @@ type PendingRequest = { resolve: (result: unknown) => void reject: (error: Error) => void timer: ReturnType + cleanup: () => void } export type NotificationHandler = (method: string, params: Record) => void @@ -127,10 +130,19 @@ export class SshChannelMultiplexer { /** * Send a JSON-RPC request and wait for the response. */ - async request(method: string, params?: Record): Promise { + async request( + method: string, + params?: Record, + options?: { signal?: AbortSignal; timeoutMs?: number } + ): Promise { if (this.disposed) { throw new Error('Multiplexer disposed') } + if (options?.signal?.aborted) { + const error = new Error(`Request "${method}" was cancelled`) as Error & { name: string } + error.name = 'AbortError' + throw error + } const id = this.nextRequestId++ const msg: JsonRpcRequest = { @@ -139,14 +151,46 @@ export class SshChannelMultiplexer { method, ...(params !== undefined ? { params } : {}) } + const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS return new Promise((resolve, reject) => { - const timer = setTimeout(() => { + let timer: ReturnType + const cleanup = (): void => { + clearTimeout(timer) + if (options?.signal) { + options.signal.removeEventListener('abort', onAbort) + } + } + const onAbort = (): void => { + const pending = this.pendingRequests.get(id) + if (!pending) { + return + } + pending.cleanup() this.pendingRequests.delete(id) - reject(new Error(`Request "${method}" timed out after ${REQUEST_TIMEOUT_MS}ms`)) - }, REQUEST_TIMEOUT_MS) + // Why: Space scans can run long on SSH hosts. Let the relay stop its + // local filesystem work instead of only dropping the client promise. + this.notify('rpc.cancel', { id }) + const error = new Error(`Request "${method}" was cancelled`) as Error & { name: string } + error.name = 'AbortError' + pending.reject(error) + } + timer = setTimeout(() => { + const pending = this.pendingRequests.get(id) + if (pending) { + pending.cleanup() + // Why: request timeouts should stop relay-side long-running work, + // not just detach the client from the eventual response. + this.notify('rpc.cancel', { id }) + } + this.pendingRequests.delete(id) + reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`)) + }, timeoutMs) - this.pendingRequests.set(id, { resolve, reject, timer }) + if (options?.signal) { + options.signal.addEventListener('abort', onAbort, { once: true }) + } + this.pendingRequests.set(id, { resolve, reject, timer, cleanup }) this.sendMessage(msg) }) } @@ -194,7 +238,7 @@ export class SshChannelMultiplexer { const errorCode = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' for (const [id, pending] of this.pendingRequests) { - clearTimeout(pending.timer) + pending.cleanup() const err = new Error(errorMessage) as Error & { code: string } err.code = errorCode pending.reject(err) @@ -295,7 +339,7 @@ export class SshChannelMultiplexer { return } - clearTimeout(pending.timer) + pending.cleanup() this.pendingRequests.delete(msg.id) if (msg.error) { diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index 041219501c8..f2ab549b915 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: scan, cancellation, SSH fallback, and compaction tests share temp-repo fixtures. */ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -32,7 +33,7 @@ vi.mock('./providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) -import { analyzeWorkspaceSpace } from './workspace-space-analysis' +import { analyzeWorkspaceSpace, WorkspaceSpaceScanCancelledError } from './workspace-space-analysis' function createStore(repos: Repo[]): Store { return { @@ -123,6 +124,70 @@ describe('analyzeWorkspaceSpace', () => { expect(result.reclaimableBytes).toBe(feature?.sizeBytes) }) + it('reports scan progress as repos and worktrees are scanned', async () => { + const root = tempDir! + const repoPath = join(root, 'repo') + await mkdir(repoPath, { recursive: true }) + await writeSizedFile(join(repoPath, 'file.txt'), 128) + const repo: Repo = { + id: 'repo-1', + path: repoPath, + displayName: 'orca', + badgeColor: '#000', + addedAt: 0 + } + listRepoWorktreesMock.mockResolvedValue([ + { + path: repoPath, + head: 'a', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + const progress: unknown[] = [] + + await analyzeWorkspaceSpace(createStore([repo]), { + scanId: 'scan-1', + onProgress: (event) => progress.push(event) + }) + + expect(progress[0]).toMatchObject({ + scanId: 'scan-1', + totalRepoCount: 1, + scannedRepoCount: 0, + totalWorktreeCount: 0, + scannedWorktreeCount: 0 + }) + expect(progress).toContainEqual( + expect.objectContaining({ + totalWorktreeCount: 1, + currentRepoDisplayName: 'orca' + }) + ) + expect(progress.at(-1)).toMatchObject({ + scannedRepoCount: 1, + scannedWorktreeCount: 1 + }) + }) + + it('rejects when a scan is cancelled before it starts', async () => { + const repo: Repo = { + id: 'repo-1', + path: tempDir!, + displayName: 'orca', + badgeColor: '#000', + addedAt: 0 + } + const controller = new AbortController() + controller.abort() + + await expect( + analyzeWorkspaceSpace(createStore([repo]), { signal: controller.signal }) + ).rejects.toBeInstanceOf(WorkspaceSpaceScanCancelledError) + expect(listRepoWorktreesMock).not.toHaveBeenCalled() + }) + it('isolates missing worktrees as row-level scan failures', async () => { const root = tempDir! const repoPath = join(root, 'repo') @@ -219,6 +284,55 @@ describe('analyzeWorkspaceSpace', () => { expect(statMock).not.toHaveBeenCalledWith('/remote/feature/linked-cache') }) + it('uses the SSH bulk Space scan provider when available', async () => { + const repo: Repo = { + id: 'repo-remote', + path: '/remote/repo', + displayName: 'remote', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + getSshGitProviderMock.mockReturnValue({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/feature', + head: 'c', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false + } + ]) + }) + const scanWorkspaceSpace = vi.fn().mockResolvedValue({ + sizeBytes: 4096, + skippedEntryCount: 0, + topLevelItems: [ + { + name: 'node_modules', + path: '/remote/feature/node_modules', + kind: 'directory', + sizeBytes: 4096 + } + ], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + }) + const readDir = vi.fn() + const stat = vi.fn() + getSshFilesystemProviderMock.mockReturnValue({ scanWorkspaceSpace, readDir, stat }) + + const result = await analyzeWorkspaceSpace(createStore([repo])) + + expect(scanWorkspaceSpace).toHaveBeenCalledWith( + '/remote/feature', + expect.objectContaining({ signal: undefined }) + ) + expect(readDir).not.toHaveBeenCalled() + expect(stat).not.toHaveBeenCalled() + expect(result.worktrees[0]?.sizeBytes).toBe(4096) + }) + it('reports disconnected SSH repos without failing the whole analysis', async () => { const repo: Repo = { id: 'repo-remote', diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 950f05c78bb..fd23f12b65b 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -2,7 +2,7 @@ semantics paired so reclaimable-byte, symlink, and partial-failure behavior cannot drift. */ import { lstat, readdir } from 'node:fs/promises' import { execFile } from 'node:child_process' -import { basename, posix, win32 } from 'node:path' +import { posix, win32 } from 'node:path' import { platform } from 'node:process' import { promisify } from 'node:util' import type { Dirent } from 'node:fs' @@ -11,19 +11,21 @@ import { isFolderRepo } from '../shared/repo-kind' import type { GitWorktreeInfo, Repo, Worktree } from '../shared/types' import type { WorkspaceSpaceAnalysis, + WorkspaceSpaceDirectoryScanResult, WorkspaceSpaceItem, WorkspaceSpaceItemKind, WorkspaceSpaceRepoSummary, + WorkspaceSpaceScanProgress, WorkspaceSpaceScanStatus, WorkspaceSpaceWorktree } from '../shared/workspace-space-types' +import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' import type { IFilesystemProvider } from './providers/types' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import { getSshGitProvider } from './providers/ssh-git-dispatch' import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees' import { mergeWorktree } from './ipc/worktree-logic' -const MAX_TOP_LEVEL_ITEMS = 48 const WORKTREE_SCAN_CONCURRENCY = 3 const LOCAL_FS_CONCURRENCY = 48 const REMOTE_FS_CONCURRENCY = 10 @@ -51,16 +53,78 @@ type RepoScanResult = { worktrees: WorkspaceSpaceWorktree[] } -function createAsyncLimiter(maxConcurrent: number): AsyncLimiter { +type WorkspaceSpaceAnalyzeOptions = { + signal?: AbortSignal + scanId?: string + onProgress?: (progress: WorkspaceSpaceScanProgress) => void +} + +type WorkspaceSpaceProgressState = WorkspaceSpaceScanProgress + +export class WorkspaceSpaceScanCancelledError extends Error { + constructor() { + super('Workspace space scan cancelled') + this.name = 'WorkspaceSpaceScanCancelledError' + } +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new WorkspaceSpaceScanCancelledError() + } +} + +function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + return (error as { name?: unknown }).name === 'AbortError' +} + +function isRelayMethodNotFoundError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + return (error as { code?: unknown }).code === -32601 +} + +function createAsyncLimiter(maxConcurrent: number, signal?: AbortSignal): AsyncLimiter { let active = 0 - const queue: (() => void)[] = [] + const queue: { resolve: () => void; reject: (error: Error) => void }[] = [] const acquire = async (): Promise => { + throwIfAborted(signal) if (active < maxConcurrent) { active += 1 return } - await new Promise((resolve) => queue.push(resolve)) + await new Promise((resolve, reject) => { + let onAbort: (() => void) | null = null + const waiter = { + resolve: () => { + if (onAbort) { + signal?.removeEventListener('abort', onAbort) + } + resolve() + }, + reject + } + onAbort = () => { + const index = queue.indexOf(waiter) + if (index !== -1) { + queue.splice(index, 1) + } + reject(new WorkspaceSpaceScanCancelledError()) + } + queue.push(waiter) + if (signal) { + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + } + }) + throwIfAborted(signal) active += 1 } @@ -71,9 +135,7 @@ function createAsyncLimiter(maxConcurrent: number): AsyncLimiter { } finally { active -= 1 const next = queue.shift() - if (next) { - next() - } + next?.resolve() } } } @@ -91,23 +153,32 @@ function looksLikeWindowsPath(pathValue: string): boolean { return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') } +function basenameFilesystemPath(pathValue: string): string { + return looksLikeWindowsPath(pathValue) ? win32.basename(pathValue) : posix.basename(pathValue) +} + function joinFilesystemPath(parent: string, child: string): string { return looksLikeWindowsPath(parent) ? win32.join(parent, child) : posix.join(parent, child) } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + function normalizeLocalDuPath(pathValue: string): string { - const trimmed = pathValue.replace(/[\\/]+$/, '') + const separator = platform === 'win32' ? '\\' : '/' + const trimmed = pathValue.replace(new RegExp(`${escapeRegExp(separator)}+$`), '') return trimmed.length > 0 ? trimmed : pathValue } function parseDuDepthOneOutput(stdout: string): Map { const sizes = new Map() for (const line of stdout.split('\n')) { - const trimmed = line.trim() - if (!trimmed) { + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line + if (!normalizedLine) { continue } - const match = /^(\d+)\s+(.+)$/.exec(trimmed) + const match = /^(\d+)\s+(.+)$/.exec(normalizedLine) if (!match) { continue } @@ -116,10 +187,14 @@ function parseDuDepthOneOutput(stdout: string): Map { return sizes } -async function readLocalDuDepthOne(rootPath: string): Promise> { +async function readLocalDuDepthOne( + rootPath: string, + signal?: AbortSignal +): Promise> { const { stdout } = await execFileAsync('du', ['-k', '-d', '1', rootPath], { encoding: 'utf8', maxBuffer: DU_MAX_BUFFER_BYTES, + signal, timeout: DU_TIMEOUT_MS }) return parseDuDepthOneOutput(stdout) @@ -153,44 +228,6 @@ function toWorkspaceSpaceItem(stats: ScanStats): WorkspaceSpaceItem { } } -function compactTopLevelItems(items: WorkspaceSpaceItem[]): { - topLevelItems: WorkspaceSpaceItem[] - omittedTopLevelItemCount: number - omittedTopLevelSizeBytes: number -} { - const sorted = [...items].sort( - (a, b) => b.sizeBytes - a.sizeBytes || a.name.localeCompare(b.name) - ) - if (sorted.length <= MAX_TOP_LEVEL_ITEMS) { - return { - topLevelItems: sorted, - omittedTopLevelItemCount: 0, - omittedTopLevelSizeBytes: 0 - } - } - - const visible = sorted.slice(0, MAX_TOP_LEVEL_ITEMS - 1) - const omitted = sorted.slice(MAX_TOP_LEVEL_ITEMS - 1) - const other = omitted.reduce( - (acc, item) => ({ - ...acc, - sizeBytes: acc.sizeBytes + item.sizeBytes - }), - { - name: 'Other', - path: '', - kind: 'other', - sizeBytes: 0 - } - ) - - return { - topLevelItems: [...visible, other], - omittedTopLevelItemCount: omitted.length, - omittedTopLevelSizeBytes: other.sizeBytes - } -} - function createBaseWorktreeRow( repo: Repo, worktree: Worktree, @@ -244,12 +281,34 @@ function createUnavailableWorktreeRow( } } +function createScannedWorktreeRow( + repo: Repo, + worktree: Worktree, + scannedAt: number, + scan: WorkspaceSpaceDirectoryScanResult +): WorkspaceSpaceWorktree { + return { + ...createBaseWorktreeRow(repo, worktree, scannedAt), + status: 'ok', + error: null, + sizeBytes: scan.sizeBytes, + reclaimableBytes: worktree.isMainWorktree ? 0 : scan.sizeBytes, + skippedEntryCount: scan.skippedEntryCount, + topLevelItems: scan.topLevelItems, + omittedTopLevelItemCount: scan.omittedTopLevelItemCount, + omittedTopLevelSizeBytes: scan.omittedTopLevelSizeBytes + } +} + async function scanLocalEntry( entryPath: string, name: string, - limit: AsyncLimiter + limit: AsyncLimiter, + signal?: AbortSignal ): Promise { + throwIfAborted(signal) const stats = await limit(() => lstat(entryPath)) + throwIfAborted(signal) if (stats.isSymbolicLink()) { return { @@ -289,8 +348,16 @@ async function scanLocalEntry( const childStats = await Promise.all( entries.map(async (entry): Promise => { try { - return await scanLocalEntry(joinFilesystemPath(entryPath, entry.name), entry.name, limit) - } catch { + return await scanLocalEntry( + joinFilesystemPath(entryPath, entry.name), + entry.name, + limit, + signal + ) + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } return null } }) @@ -322,8 +389,10 @@ async function scanRemoteEntry( name: string, provider: IFilesystemProvider, limit: AsyncLimiter, + signal?: AbortSignal, knownSymlink = false ): Promise { + throwIfAborted(signal) if (knownSymlink) { return { name, @@ -335,6 +404,7 @@ async function scanRemoteEntry( } const stats = await limit(() => provider.stat(entryPath)) + throwIfAborted(signal) if (stats.type === 'symlink') { return { name, @@ -358,7 +428,11 @@ async function scanRemoteEntry( let entries try { entries = await limit(() => provider.readDir(entryPath)) - } catch { + throwIfAborted(signal) + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } return { name, path: entryPath, @@ -376,9 +450,13 @@ async function scanRemoteEntry( entry.name, provider, limit, + signal, entry.isSymlink ) - } catch { + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } return null } }) @@ -409,9 +487,12 @@ async function scanLocalTopLevelEntry( entryPath: string, name: string, duSizes: Map, - limit: AsyncLimiter + limit: AsyncLimiter, + signal?: AbortSignal ): Promise { + throwIfAborted(signal) const stats = await limit(() => lstat(entryPath)) + throwIfAborted(signal) if (stats.isSymbolicLink()) { return { @@ -445,13 +526,20 @@ async function scanLocalTopLevelEntry( async function scanLocalWorktreeWithDu( repo: Repo, worktree: Worktree, - scannedAt: number + scannedAt: number, + signal?: AbortSignal ): Promise { + throwIfAborted(signal) const rootStats = await lstat(worktree.path) if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { - const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY) - const root = await scanLocalEntry(worktree.path, basename(worktree.path), limit) - const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem)) + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) + const root = await scanLocalEntry( + worktree.path, + basenameFilesystemPath(worktree.path), + limit, + signal + ) + const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) return { ...createBaseWorktreeRow(repo, worktree, scannedAt), status: 'ok', @@ -465,9 +553,10 @@ async function scanLocalWorktreeWithDu( const [entries, duSizes] = await Promise.all([ readdir(worktree.path, { withFileTypes: true }), - readLocalDuDepthOne(worktree.path) + readLocalDuDepthOne(worktree.path, signal) ]) - const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY) + throwIfAborted(signal) + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) const childStats = await Promise.all( entries.map(async (entry): Promise => { try { @@ -475,9 +564,13 @@ async function scanLocalWorktreeWithDu( joinFilesystemPath(worktree.path, entry.name), entry.name, duSizes, - limit + limit, + signal ) - } catch { + } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } return null } }) @@ -487,7 +580,7 @@ async function scanLocalWorktreeWithDu( const rootSize = duSizes.get(normalizeLocalDuPath(worktree.path)) ?? rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0) - const compact = compactTopLevelItems(children.map(toWorkspaceSpaceItem)) + const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) return { ...createBaseWorktreeRow(repo, worktree, scannedAt), @@ -503,12 +596,18 @@ async function scanLocalWorktreeWithDu( async function scanLocalWorktreeWithNode( repo: Repo, worktree: Worktree, - scannedAt: number + scannedAt: number, + signal?: AbortSignal ): Promise { try { - const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY) - const root = await scanLocalEntry(worktree.path, basename(worktree.path), limit) - const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem)) + const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal) + const root = await scanLocalEntry( + worktree.path, + basenameFilesystemPath(worktree.path), + limit, + signal + ) + const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) return { ...createBaseWorktreeRow(repo, worktree, scannedAt), status: 'ok', @@ -519,6 +618,9 @@ async function scanLocalWorktreeWithNode( ...compact } } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } const classified = classifyError(error) return createUnavailableWorktreeRow( repo, @@ -533,41 +635,70 @@ async function scanLocalWorktreeWithNode( async function scanLocalWorktree( repo: Repo, worktree: Worktree, - scannedAt: number + scannedAt: number, + signal?: AbortSignal ): Promise { + throwIfAborted(signal) if (platform !== 'win32') { try { // Why: JS per-file stats are too slow for large local workspace fleets; // POSIX du gives bounded top-level sizing without following symlinks. - return await scanLocalWorktreeWithDu(repo, worktree, scannedAt) - } catch { + return await scanLocalWorktreeWithDu(repo, worktree, scannedAt, signal) + } catch (error) { + throwIfAborted(signal) + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } // Fall through to the portable scanner so unsupported du variants or // permission edge cases still produce partial rows instead of failing. } } - return scanLocalWorktreeWithNode(repo, worktree, scannedAt) + return scanLocalWorktreeWithNode(repo, worktree, scannedAt, signal) } async function scanRemoteWorktree( repo: Repo, worktree: Worktree, scannedAt: number, - provider: IFilesystemProvider + provider: IFilesystemProvider, + signal?: AbortSignal ): Promise { try { - const limit = createAsyncLimiter(REMOTE_FS_CONCURRENCY) - const root = await scanRemoteEntry(worktree.path, basename(worktree.path), provider, limit) - const compact = compactTopLevelItems((root.children ?? []).map(toWorkspaceSpaceItem)) - return { - ...createBaseWorktreeRow(repo, worktree, scannedAt), - status: 'ok', - error: null, + if (provider.scanWorkspaceSpace) { + try { + const scan = await provider.scanWorkspaceSpace(worktree.path, { signal }) + return createScannedWorktreeRow(repo, worktree, scannedAt, scan) + } catch (error) { + if (isAbortError(error)) { + throw new WorkspaceSpaceScanCancelledError() + } + if (!isRelayMethodNotFoundError(error)) { + throw error + } + // Why: old SSH relays do not know the bulk Space scan method. Fall + // back to the request-by-request walker instead of marking SSH rows + // unavailable after an app upgrade. + } + } + + const limit = createAsyncLimiter(REMOTE_FS_CONCURRENCY, signal) + const root = await scanRemoteEntry( + worktree.path, + basenameFilesystemPath(worktree.path), + provider, + limit, + signal + ) + const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem)) + return createScannedWorktreeRow(repo, worktree, scannedAt, { sizeBytes: root.sizeBytes, - reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes, skippedEntryCount: root.skippedEntryCount, ...compact - } + }) } catch (error) { + if (error instanceof WorkspaceSpaceScanCancelledError) { + throw error + } const classified = classifyError(error) return createUnavailableWorktreeRow( repo, @@ -579,8 +710,12 @@ async function scanRemoteWorktree( } } -async function listWorktreesForSpaceScan(repo: Repo): Promise { +async function listWorktreesForSpaceScan( + repo: Repo, + signal?: AbortSignal +): Promise { try { + throwIfAborted(signal) if (isFolderRepo(repo)) { return { ok: true, worktrees: [createFolderWorktree(repo)] } } @@ -593,10 +728,17 @@ async function listWorktreesForSpaceScan(repo: Repo): Promise { - const listed = await listWorktreesForSpaceScan(repo) +function reportProgress( + progress: WorkspaceSpaceProgressState, + updates: Partial, + onProgress: WorkspaceSpaceAnalyzeOptions['onProgress'] +): void { + Object.assign(progress, updates, { updatedAt: Date.now() }) + onProgress?.({ ...progress }) +} + +async function scanRepo( + repo: Repo, + scannedAt: number, + store: Store, + progress: WorkspaceSpaceProgressState, + options: WorkspaceSpaceAnalyzeOptions +): Promise { + throwIfAborted(options.signal) + reportProgress( + progress, + { + currentRepoDisplayName: repo.displayName, + currentWorktreeDisplayName: null + }, + options.onProgress + ) + const listed = await listWorktreesForSpaceScan(repo, options.signal) if (!listed.ok) { + reportProgress( + progress, + { scannedRepoCount: progress.scannedRepoCount + 1 }, + options.onProgress + ) return { worktrees: [], summary: { @@ -630,22 +801,49 @@ async function scanRepo(repo: Repo, scannedAt: number, store: Store): Promise mergeForSpaceScan(repo, gitWorktree, store) ) + reportProgress( + progress, + { totalWorktreeCount: progress.totalWorktreeCount + worktrees.length }, + options.onProgress + ) const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined const rows = await mapLimit(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => { - if (repo.connectionId) { - if (!remoteProvider) { - return createUnavailableWorktreeRow( - repo, - worktree, - scannedAt, - 'unavailable', - `SSH filesystem for "${repo.connectionId}" is not connected.` - ) - } - return scanRemoteWorktree(repo, worktree, scannedAt, remoteProvider) - } - return scanLocalWorktree(repo, worktree, scannedAt) + throwIfAborted(options.signal) + reportProgress( + progress, + { + currentRepoDisplayName: repo.displayName, + currentWorktreeDisplayName: worktree.displayName + }, + options.onProgress + ) + const row: WorkspaceSpaceWorktree = repo.connectionId + ? remoteProvider + ? await scanRemoteWorktree(repo, worktree, scannedAt, remoteProvider, options.signal) + : createUnavailableWorktreeRow( + repo, + worktree, + scannedAt, + 'unavailable', + `SSH filesystem for "${repo.connectionId}" is not connected.` + ) + : await scanLocalWorktree(repo, worktree, scannedAt, options.signal) + reportProgress( + progress, + { scannedWorktreeCount: progress.scannedWorktreeCount + 1 }, + options.onProgress + ) + return row }) + reportProgress( + progress, + { + scannedRepoCount: progress.scannedRepoCount + 1, + currentRepoDisplayName: repo.displayName, + currentWorktreeDisplayName: null + }, + options.onProgress + ) return { worktrees: rows, @@ -664,11 +862,30 @@ async function scanRepo(repo: Repo, scannedAt: number, store: Store): Promise { +export async function analyzeWorkspaceSpace( + store: Store, + options: WorkspaceSpaceAnalyzeOptions = {} +): Promise { + throwIfAborted(options.signal) const scannedAt = Date.now() - const repoResults = await mapLimit(store.getRepos(), 2, (repo) => - scanRepo(repo, scannedAt, store) + const reposToScan = store.getRepos() + const progress: WorkspaceSpaceProgressState = { + scanId: options.scanId ?? String(scannedAt), + state: 'running', + startedAt: scannedAt, + updatedAt: scannedAt, + totalRepoCount: reposToScan.length, + scannedRepoCount: 0, + totalWorktreeCount: 0, + scannedWorktreeCount: 0, + currentRepoDisplayName: null, + currentWorktreeDisplayName: null + } + options.onProgress?.({ ...progress }) + const repoResults = await mapLimit(reposToScan, 2, (repo) => + scanRepo(repo, scannedAt, store, progress, options) ) + throwIfAborted(options.signal) const repos = repoResults.map((result) => result.summary) const worktrees = repoResults .flatMap((result) => result.worktrees) diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts new file mode 100644 index 00000000000..dcd465c5863 --- /dev/null +++ b/src/main/worktree-removal-safety.ts @@ -0,0 +1,79 @@ +import { lstat } from 'fs/promises' +import { homedir } from 'os' +import { posix, win32 } from 'path' +import type { GitWorktreeInfo } from '../shared/types' +import { areWorktreePathsEqual } from './ipc/worktree-logic' + +type PathOps = typeof posix + +function looksLikeWindowsPath(pathValue: string): boolean { + return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') +} + +function getPathOps(...paths: string[]): PathOps { + return paths.some(looksLikeWindowsPath) ? win32 : posix +} + +function containsPath(parentPath: string, childPath: string, pathOps: PathOps): boolean { + const relativePath = pathOps.relative(parentPath, childPath) + return ( + relativePath === '' || + (!!relativePath && !relativePath.startsWith('..') && !pathOps.isAbsolute(relativePath)) + ) +} + +export function isDangerousWorktreeRemovalPath(worktreePath: string, repoPath: string): boolean { + if (!worktreePath.trim()) { + return true + } + + if (areWorktreePathsEqual(worktreePath, repoPath)) { + return true + } + + const pathOps = getPathOps(worktreePath, repoPath) + const resolvedWorktreePath = pathOps.resolve(worktreePath) + const rootPath = pathOps.parse(resolvedWorktreePath).root + if (resolvedWorktreePath === rootPath) { + return true + } + + const resolvedRepoPath = pathOps.resolve(repoPath) + if (containsPath(resolvedWorktreePath, resolvedRepoPath, pathOps)) { + return true + } + + const homePath = homedir() + return !!homePath && containsPath(resolvedWorktreePath, pathOps.resolve(homePath), pathOps) +} + +export function getRegisteredDeletableWorktree( + repoPath: string, + requestedWorktreePath: string, + worktrees: readonly GitWorktreeInfo[] +): GitWorktreeInfo { + const worktree = worktrees.find((item) => areWorktreePathsEqual(item.path, requestedWorktreePath)) + if (!worktree) { + throw new Error(`Refusing to delete unregistered worktree path: ${requestedWorktreePath}`) + } + if (worktree.isMainWorktree || isDangerousWorktreeRemovalPath(worktree.path, repoPath)) { + throw new Error(`Refusing to delete protected worktree path: ${worktree.path}`) + } + return worktree +} + +export async function canSafelyRemoveOrphanedWorktreeDirectory( + worktreePath: string, + repoPath: string +): Promise { + if (isDangerousWorktreeRemovalPath(worktreePath, repoPath)) { + return false + } + + try { + const gitEntry = await lstat(getPathOps(worktreePath).join(worktreePath, '.git')) + return gitEntry.isFile() || gitEntry.isDirectory() || gitEntry.isSymbolicLink() + } catch { + return false + } +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 533879c1fc7..9140e0e3133 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -172,7 +172,10 @@ import type { } from '../shared/claude-usage-types' import type { RateLimitState } from '../shared/rate-limit-types' import type { SpeechModelManifest, SpeechModelState } from '../shared/speech-types' -import type { WorkspaceSpaceAnalysis } from '../shared/workspace-space-types' +import type { + WorkspaceSpaceAnalyzeResult, + WorkspaceSpaceScanProgress +} from '../shared/workspace-space-types' import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { SshConnectionState, @@ -535,7 +538,9 @@ export type PreloadApi = { ) => () => void } workspaceSpace: { - analyze: () => Promise + analyze: () => Promise + cancel: () => Promise + onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void } pty: { spawn: (opts: { diff --git a/src/preload/index.ts b/src/preload/index.ts index b9b5d34d123..6a203c2f204 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,7 +40,10 @@ import type { RuntimeMobileMarkdownResponse } from '../shared/mobile-markdown-document' import type { RateLimitState } from '../shared/rate-limit-types' -import type { WorkspaceSpaceAnalysis } from '../shared/workspace-space-types' +import type { + WorkspaceSpaceAnalyzeResult, + WorkspaceSpaceScanProgress +} from '../shared/workspace-space-types' import type { GhAuthDiagnostic } from '../shared/github-auth-types' import type { AddIssueCommentBySlugArgs, @@ -495,7 +498,17 @@ const api = { }, workspaceSpace: { - analyze: (): Promise => ipcRenderer.invoke('workspaceSpace:analyze') + analyze: (): Promise => + ipcRenderer.invoke('workspaceSpace:analyze'), + cancel: (): Promise => ipcRenderer.invoke('workspaceSpace:cancel'), + onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + progress: WorkspaceSpaceScanProgress + ): void => callback(progress) + ipcRenderer.on('workspaceSpace:progress', listener) + return () => ipcRenderer.removeListener('workspaceSpace:progress', listener) + } }, pty: { diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index e5bf73f6d48..53733dcabd2 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -247,4 +247,26 @@ describe('RelayDispatcher', () => { }) expect(responses).toHaveLength(0) }) + + it('aborts in-flight request contexts after client invalidation', async () => { + let observedSignal: AbortSignal | undefined + let resolveHandler!: () => void + dispatcher.onRequest( + 'slow.method', + (_params, context) => + new Promise((resolve) => { + observedSignal = context.signal + resolveHandler = () => resolve(null) + }) + ) + + const req: JsonRpcRequest = { jsonrpc: '2.0', id: 100, method: 'slow.method' } + dispatcher.feed(encodeJsonRpcFrame(req, 1, 0)) + await vi.advanceTimersByTimeAsync(0) + dispatcher.invalidateClient() + + expect(observedSignal?.aborted).toBe(true) + resolveHandler() + await vi.advanceTimersByTimeAsync(0) + }) }) diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index b827195ba4b..1cd2cce034b 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -13,6 +13,7 @@ import { export type RequestContext = { isStale: () => boolean + signal?: AbortSignal } export type MethodHandler = ( @@ -27,6 +28,7 @@ export class RelayDispatcher { private write: (data: Buffer) => void private requestHandlers = new Map() private notificationHandlers = new Map() + private requestAbortControllers = new Map() private nextOutgoingSeq = 1 private highestReceivedSeq = 0 private keepaliveTimer: ReturnType | null = null @@ -54,6 +56,7 @@ export class RelayDispatcher { // up — causing the client's unacked-timeout checker to accumulate stale // timestamps that could eventually fire a false connection-dead signal. setWrite(write: (data: Buffer) => void): void { + this.abortActiveRequests() this.write = write this.nextOutgoingSeq = 1 this.highestReceivedSeq = 0 @@ -65,6 +68,7 @@ export class RelayDispatcher { // disconnects even if no replacement has connected yet. Otherwise a late // pty.spawn/fs.watch completion can create remote state nobody can own. invalidateClient(): void { + this.abortActiveRequests() this.generation++ } @@ -76,6 +80,13 @@ export class RelayDispatcher { this.notificationHandlers.set(method, handler) } + private abortActiveRequests(): void { + for (const controller of this.requestAbortControllers.values()) { + controller.abort() + } + this.requestAbortControllers.clear() + } + feed(data: Buffer): void { if (this.disposed) { return @@ -157,26 +168,37 @@ export class RelayDispatcher { // space. Sending it would misroute — the new client may have issued // its own request with the same JSON-RPC id. const gen = this.generation + const abortController = new AbortController() + this.requestAbortControllers.set(req.id, abortController) const context: RequestContext = { - isStale: () => this.generation !== gen + isStale: () => this.generation !== gen || abortController.signal.aborted, + signal: abortController.signal } try { const result = await handler(req.params ?? {}, context) - if (this.generation !== gen) { + if (this.generation !== gen || abortController.signal.aborted) { return } this.sendResponse(req.id, result) } catch (err) { - if (this.generation !== gen) { + if (this.generation !== gen || abortController.signal.aborted) { return } const message = err instanceof Error ? err.message : String(err) const code = (err as { code?: number }).code ?? -32000 this.sendResponse(req.id, undefined, { code, message }) + } finally { + this.requestAbortControllers.delete(req.id) } } private handleNotification(notif: JsonRpcNotification): void { + if (notif.method === 'rpc.cancel') { + const id = Number((notif.params ?? {}).id) + const controller = this.requestAbortControllers.get(id) + controller?.abort() + return + } const handler = this.notificationHandlers.get(notif.method) if (handler) { handler(notif.params ?? {}) diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index 590e9ea4665..d17df8c44a6 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: this suite covers relay filesystem RPCs, - file watcher lifecycle edges, and cross-platform path behavior together. */ + Space scans, file watcher lifecycle edges, and cross-platform path behavior together. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { RelayContext } from './context' @@ -101,6 +101,7 @@ describe('FsHandler', () => { expect(methods).toContain('fs.realpath') expect(methods).toContain('fs.search') expect(methods).toContain('fs.listFiles') + expect(methods).toContain('fs.workspaceSpaceScan') expect(methods).toContain('fs.watch') const notifMethods = Array.from(dispatcher._notificationHandlers.keys()) @@ -247,6 +248,25 @@ describe('FsHandler', () => { expect(result.type).toBe('directory') }) + it('workspaceSpaceScan returns bounded top-level size details', async () => { + mkdirSync(path.join(tmpDir, 'node_modules')) + writeFileSync(path.join(tmpDir, 'node_modules', 'pkg.js'), Buffer.alloc(512)) + writeFileSync(path.join(tmpDir, 'file.log'), Buffer.alloc(128)) + + const result = (await dispatcher.callRequest( + 'fs.workspaceSpaceScan', + { rootPath: tmpDir }, + { isStale: () => false } + )) as { + sizeBytes: number + topLevelItems: { name: string; sizeBytes: number }[] + } + + expect(result.sizeBytes).toBeGreaterThanOrEqual(640) + expect(result.topLevelItems.map((item) => item.name)).toContain('node_modules') + expect(result.topLevelItems.map((item) => item.name)).toContain('file.log') + }) + it('deletePath removes files', async () => { const filePath = path.join(tmpDir, 'to-delete.txt') writeFileSync(filePath, 'bye') diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index 3bedb3c856b..a2d6f2aa2a3 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: relay filesystem request handling shares - path expansion, file IO, search, streaming reads, and watch lifecycle state. */ + path expansion, file IO, search, streaming reads, Space scans, and watch lifecycle state. */ import { readdir, writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'fs/promises' import { execFile } from 'child_process' import { join } from 'path' @@ -20,6 +20,7 @@ import { buildExcludePathPrefixes } from '../shared/quick-open-filter' import { buildInstallRgMessage } from './fs-handler-install-rg' import { readRelayFileContent, readRelayFileStreamMetadata } from './fs-handler-file-read' import { RelayStreamRegistry } from './fs-stream-registry' +import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' type WatchState = { rootPath: string @@ -72,6 +73,7 @@ export class FsHandler { this.dispatcher.onRequest('fs.realpath', (p) => this.realpath(p)) this.dispatcher.onRequest('fs.search', (p) => this.search(p)) this.dispatcher.onRequest('fs.listFiles', (p) => this.listFiles(p)) + this.dispatcher.onRequest('fs.workspaceSpaceScan', (p, c) => this.workspaceSpaceScan(p, c)) this.dispatcher.onRequest('fs.watch', (p, context) => this.watch(p, context)) this.dispatcher.onNotification('fs.unwatch', (p) => this.unwatch(p)) this.dispatcher.onNotification('fs.cancelStream', (p) => this.cancelStream(p)) @@ -279,6 +281,11 @@ export class FsHandler { } } + private async workspaceSpaceScan(params: Record, context: RequestContext) { + const rootPath = expandTilde(params.rootPath as string) + return scanWorkspaceSpaceDirectory(rootPath, context) + } + private async watch(params: Record, context?: RequestContext) { const rootPath = expandTilde(params.rootPath as string) diff --git a/src/relay/workspace-space-scan.ts b/src/relay/workspace-space-scan.ts new file mode 100644 index 00000000000..cc823457142 --- /dev/null +++ b/src/relay/workspace-space-scan.ts @@ -0,0 +1,375 @@ +/* eslint-disable max-lines -- Why: local and relay Space scans share the same + cancellation, symlink, and top-level compaction semantics in one scanner. */ +import { execFile } from 'node:child_process' +import type { Dirent } from 'node:fs' +import { lstat, readdir } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { platform } from 'node:process' +import { promisify } from 'node:util' +import type { + WorkspaceSpaceDirectoryScanResult, + WorkspaceSpaceItem, + WorkspaceSpaceItemKind +} from '../shared/workspace-space-types' +import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction' +import type { RequestContext } from './dispatcher' + +const RELAY_FS_CONCURRENCY = 48 +const DU_TIMEOUT_MS = 120_000 +const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024 +const execFileAsync = promisify(execFile) + +type AsyncLimiter = (task: () => Promise) => Promise + +type ScanStats = { + name: string + path: string + kind: WorkspaceSpaceItemKind + sizeBytes: number + skippedEntryCount: number +} + +class RelayWorkspaceSpaceScanCancelledError extends Error { + constructor() { + super('Workspace space scan cancelled') + this.name = 'RelayWorkspaceSpaceScanCancelledError' + } +} + +function throwIfCancelled(context: RequestContext): void { + if (context.isStale() || context.signal?.aborted) { + throw new RelayWorkspaceSpaceScanCancelledError() + } +} + +function createAsyncLimiter(maxConcurrent: number, context: RequestContext): AsyncLimiter { + let active = 0 + const queue: { resolve: () => void }[] = [] + + const acquire = async (): Promise => { + throwIfCancelled(context) + if (active < maxConcurrent) { + active += 1 + return + } + await new Promise((resolve, reject) => { + let onAbort: (() => void) | null = null + const waiter = { + resolve: () => { + if (onAbort) { + context.signal?.removeEventListener('abort', onAbort) + } + resolve() + } + } + onAbort = () => { + const index = queue.indexOf(waiter) + if (index !== -1) { + queue.splice(index, 1) + } + reject(new RelayWorkspaceSpaceScanCancelledError()) + } + queue.push(waiter) + if (context.signal) { + context.signal.addEventListener('abort', onAbort, { once: true }) + if (context.signal.aborted) { + onAbort() + } + } + }) + throwIfCancelled(context) + active += 1 + } + + return async (task: () => Promise): Promise => { + await acquire() + try { + return await task() + } finally { + active -= 1 + const next = queue.shift() + next?.resolve() + } + } +} + +function normalizeDuPath(pathValue: string): string { + const trimmed = pathValue.replace(/\/+$/, '') + return trimmed.length > 0 ? trimmed : pathValue +} + +function parseDuDepthOneOutput(stdout: string): Map { + const sizes = new Map() + for (const line of stdout.split('\n')) { + const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line + if (!normalizedLine) { + continue + } + const match = /^(\d+)\s+(.+)$/.exec(normalizedLine) + if (!match) { + continue + } + sizes.set(normalizeDuPath(match[2]), Number(match[1]) * 1024) + } + return sizes +} + +async function readDuDepthOne( + rootPath: string, + context: RequestContext +): Promise> { + throwIfCancelled(context) + const { stdout } = await execFileAsync('du', ['-k', '-d', '1', rootPath], { + encoding: 'utf8', + maxBuffer: DU_MAX_BUFFER_BYTES, + signal: context.signal, + timeout: DU_TIMEOUT_MS + }) + throwIfCancelled(context) + return parseDuDepthOneOutput(stdout) +} + +function toWorkspaceSpaceItem(stats: ScanStats): WorkspaceSpaceItem { + return { + name: stats.name, + path: stats.path, + kind: stats.kind, + sizeBytes: stats.sizeBytes + } +} + +async function scanTopLevelEntryWithDu( + entryPath: string, + name: string, + duSizes: Map, + limit: AsyncLimiter, + context: RequestContext +): Promise { + throwIfCancelled(context) + const stats = await limit(() => lstat(entryPath)) + throwIfCancelled(context) + + if (stats.isSymbolicLink()) { + return { + name, + path: entryPath, + kind: 'symlink', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + if (!stats.isDirectory()) { + return { + name, + path: entryPath, + kind: 'file', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: duSizes.get(normalizeDuPath(entryPath)) ?? stats.size, + skippedEntryCount: 0 + } +} + +async function scanEntryAggregate( + entryPath: string, + name: string, + limit: AsyncLimiter, + context: RequestContext +): Promise { + throwIfCancelled(context) + const stats = await limit(() => lstat(entryPath)) + throwIfCancelled(context) + + if (stats.isSymbolicLink()) { + return { + name, + path: entryPath, + kind: 'symlink', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + if (!stats.isDirectory()) { + return { + name, + path: entryPath, + kind: 'file', + sizeBytes: stats.size, + skippedEntryCount: 0 + } + } + + let entries: Dirent[] + try { + entries = await limit(() => readdir(entryPath, { withFileTypes: true })) + } catch { + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes: stats.size, + skippedEntryCount: 1 + } + } + + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanEntryAggregate(join(entryPath, entry.name), entry.name, limit, context) + } catch (error) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + throw error + } + return null + } + }) + ) + + let sizeBytes = stats.size + let skippedEntryCount = 0 + for (const child of childStats) { + if (!child) { + skippedEntryCount += 1 + continue + } + sizeBytes += child.sizeBytes + skippedEntryCount += child.skippedEntryCount + } + + return { + name, + path: entryPath, + kind: 'directory', + sizeBytes, + skippedEntryCount + } +} + +async function scanDirectoryWithDu( + rootPath: string, + context: RequestContext +): Promise { + throwIfCancelled(context) + const rootStats = await lstat(rootPath) + throwIfCancelled(context) + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { + return scanDirectoryWithNode(rootPath, context) + } + + const [entries, duSizes] = await Promise.all([ + readdir(rootPath, { withFileTypes: true }), + readDuDepthOne(rootPath, context) + ]) + throwIfCancelled(context) + const limit = createAsyncLimiter(RELAY_FS_CONCURRENCY, context) + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanTopLevelEntryWithDu( + join(rootPath, entry.name), + entry.name, + duSizes, + limit, + context + ) + } catch (error) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + throw error + } + return null + } + }) + ) + const children = childStats.filter((child): child is ScanStats => child !== null) + const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) + + return { + sizeBytes: + duSizes.get(normalizeDuPath(rootPath)) ?? + rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0), + skippedEntryCount: childStats.length - children.length, + ...compact + } +} + +async function scanDirectoryWithNode( + rootPath: string, + context: RequestContext +): Promise { + throwIfCancelled(context) + const limit = createAsyncLimiter(RELAY_FS_CONCURRENCY, context) + const rootStats = await lstat(rootPath) + throwIfCancelled(context) + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { + const root = await scanEntryAggregate(rootPath, basename(rootPath), limit, context) + return { + sizeBytes: root.sizeBytes, + skippedEntryCount: root.skippedEntryCount, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + } + + let entries: Dirent[] + try { + entries = await readdir(rootPath, { withFileTypes: true }) + } catch { + return { + sizeBytes: rootStats.size, + skippedEntryCount: 1, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + } + + const childStats = await Promise.all( + entries.map(async (entry): Promise => { + try { + return await scanEntryAggregate(join(rootPath, entry.name), entry.name, limit, context) + } catch (error) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + throw error + } + return null + } + }) + ) + const children = childStats.filter((child): child is ScanStats => child !== null) + const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem)) + + return { + sizeBytes: rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0), + skippedEntryCount: + children.reduce((sum, child) => sum + child.skippedEntryCount, 0) + + childStats.length - + children.length, + ...compact + } +} + +export async function scanWorkspaceSpaceDirectory( + rootPath: string, + context: RequestContext +): Promise { + if (platform !== 'win32') { + try { + return await scanDirectoryWithDu(rootPath, context) + } catch (error) { + if (error instanceof RelayWorkspaceSpaceScanCancelledError) { + throw error + } + } + } + return scanDirectoryWithNode(rootPath, context) +} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 335a7f5b2c2..2854673162a 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button' import { AlertTriangle, Check, LoaderCircle, Trash2 } from 'lucide-react' import { useAppStore } from '@/store' import { toast } from 'sonner' -import { runWorktreeDeleteWithToast } from './delete-worktree-flow' +import { runWorktreeDeletesSequentially } from './delete-worktree-flow' const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -58,6 +58,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { const canForceDelete = deleteState?.canForceDelete ?? false const confirmButtonRef = useRef(null) const isBatchDelete = worktreeIds.length > 1 + const allowSkipConfirm = !isBatchDelete && modalData.allowSkipConfirm !== false // Why: the main worktree is the repo's original clone directory — `git worktree remove` // always rejects it. We block the delete button upfront so the user doesn't have to // discover this limitation via a confusing force-delete dead-end. @@ -136,7 +137,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { // Saving "don't ask again" from that state would conflate the recovery // action with a broader preference. Only persist the preference on the // primary (non-force) confirmation so users intentionally opt in. - if (dontAskAgain && !force) { + if (dontAskAgain && allowSkipConfirm && !force) { persistDontAskAgainPreference() } if (force) { @@ -160,12 +161,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { }) }) } else { - void Promise.all( - worktrees.map((target) => runWorktreeDeleteWithToast(target.id, target.displayName)) - ).then((results) => { - const deletedIds = worktrees - .filter((_, index) => results[index]) - .map((target) => target.id) + void runWorktreeDeletesSequentially(worktrees).then((deletedIds) => { if (deletedIds.length > 0) { onDeleted?.(deletedIds) } @@ -176,6 +172,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { [ closeModal, dontAskAgain, + allowSkipConfirm, onDeleted, persistDontAskAgainPreference, removeWorktree, @@ -262,7 +259,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { )} - {!isMainWorktree && !canForceDelete && ( + {!isMainWorktree && allowSkipConfirm && !canForceDelete && ( // Why: only show "Don't ask again" for the primary confirmation. The // force-delete variant is a recovery path that shouldn't double as a // preference checkpoint; see handleDelete for the matching guard. diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts index c562a9008b1..2c1556833a5 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts @@ -73,7 +73,8 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.clearWorktreeDeleteState).toHaveBeenCalledWith('wt-2') expect(mocks.state.clearWorktreeDeleteState).not.toHaveBeenCalledWith('main') expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { - worktreeIds: ['wt-1', 'wt-2'] + worktreeIds: ['wt-1', 'wt-2'], + allowSkipConfirm: false }) }) @@ -86,7 +87,7 @@ describe('runWorktreeBatchDelete', () => { expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { worktreeId: 'wt-1' }) }) - it('runs every eligible delete immediately when confirmation is skipped', async () => { + it('keeps batch deletes behind confirmation when confirmation is skipped', () => { mocks.state.settings = { skipDeleteWorktreeConfirm: true } setWorktrees([ { id: 'wt-1', displayName: 'one' }, @@ -96,12 +97,43 @@ describe('runWorktreeBatchDelete', () => { const started = runWorktreeBatchDelete(['wt-1', 'wt-2'], { onDeleted }) + expect(started).toBe(true) + expect(mocks.state.removeWorktree).not.toHaveBeenCalled() + expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { + worktreeIds: ['wt-1', 'wt-2'], + allowSkipConfirm: false, + onDeleted + }) + }) + + it('runs a single eligible delete immediately when confirmation is skipped', async () => { + mocks.state.settings = { skipDeleteWorktreeConfirm: true } + setWorktrees([{ id: 'wt-1', displayName: 'one' }]) + const onDeleted = vi.fn() + + const started = runWorktreeBatchDelete(['wt-1'], { onDeleted }) + expect(started).toBe(true) expect(mocks.state.openModal).not.toHaveBeenCalled() expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-1', false) - expect(mocks.state.removeWorktree).toHaveBeenCalledWith('wt-2', false) await vi.waitFor(() => { - expect(onDeleted).toHaveBeenCalledWith(['wt-1', 'wt-2']) + expect(onDeleted).toHaveBeenCalledWith(['wt-1']) + }) + }) + + it('can force confirmation for a single eligible delete', () => { + mocks.state.settings = { skipDeleteWorktreeConfirm: true } + setWorktrees([{ id: 'wt-1', displayName: 'one' }]) + const onDeleted = vi.fn() + + const started = runWorktreeBatchDelete(['wt-1'], { forceConfirm: true, onDeleted }) + + expect(started).toBe(true) + expect(mocks.state.removeWorktree).not.toHaveBeenCalled() + expect(mocks.state.openModal).toHaveBeenCalledWith('delete-worktree', { + worktreeId: 'wt-1', + allowSkipConfirm: false, + onDeleted }) }) diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.ts index e09200650cc..f915b13f98a 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/delete-worktree-flow.ts @@ -6,6 +6,7 @@ import { getDeleteWorktreeToastCopy } from './delete-worktree-toast' import type { Worktree } from '../../../../shared/types' type WorktreeBatchDeleteOptions = { + forceConfirm?: boolean onDeleted?: (worktreeIds: string[]) => void } @@ -21,6 +22,21 @@ function viewWorktreeDiff(worktreeId: string): void { state.setRightSidebarOpen(true) } +export async function runWorktreeDeletesSequentially( + targets: readonly Pick[] +): Promise { + const deletedIds: string[] = [] + for (const target of targets) { + // Why: git worktree removals for one repo contend on git lock files. + // Running the user-selected batch sequentially avoids partial lock races. + const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName) + if (deleted) { + deletedIds.push(target.id) + } + } + return deletedIds +} + /** * Shared delete-with-toast flow used by both DeleteWorktreeDialog (confirm * path) and WorktreeContextMenu (skip-confirm path). Centralizes the error @@ -158,12 +174,14 @@ export function runWorktreeBatchDelete( state.clearWorktreeDeleteState(target.id) } - const skipConfirm = state.settings?.skipDeleteWorktreeConfirm ?? false + // Why: bulk cleanup can destroy many directories at once, so batch deletes + // and Space-triggered deletes must keep an explicit confirmation step. + const skipConfirm = + !options.forceConfirm && + targets.length === 1 && + (state.settings?.skipDeleteWorktreeConfirm ?? false) if (skipConfirm) { - void Promise.all( - targets.map((target) => runWorktreeDeleteWithToast(target.id, target.displayName)) - ).then((results) => { - const deletedIds = targets.filter((_, index) => results[index]).map((target) => target.id) + void runWorktreeDeletesSequentially(targets).then((deletedIds) => { if (deletedIds.length > 0) { options.onDeleted?.(deletedIds) } @@ -174,6 +192,7 @@ export function runWorktreeBatchDelete( if (targets.length === 1) { state.openModal('delete-worktree', { worktreeId: targets[0].id, + ...(options.forceConfirm ? { allowSkipConfirm: false } : {}), ...(options.onDeleted ? { onDeleted: options.onDeleted } : {}) }) return true @@ -181,6 +200,7 @@ export function runWorktreeBatchDelete( state.openModal('delete-worktree', { worktreeIds: targets.map((target) => target.id), + allowSkipConfirm: false, ...(options.onDeleted ? { onDeleted: options.onDeleted } : {}) }) return true diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx index 217816d5491..a324d94fdb4 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceCompactPanel.tsx @@ -1,9 +1,13 @@ import { useCallback } from 'react' -import { AlertTriangle, HardDrive, Loader2, RefreshCw } from 'lucide-react' +import { AlertTriangle, HardDrive, Loader2, RefreshCw, X } from 'lucide-react' import { useAppStore } from '../../store' import { Badge } from '../ui/badge' import { Button } from '../ui/button' -import { formatBytes, getWorkspaceSpaceScanTimeLabel } from './workspace-space-format' +import { + formatBytes, + getWorkspaceSpaceProgressLabel, + getWorkspaceSpaceScanTimeLabel +} from './workspace-space-format' export function WorkspaceSpaceCompactPanel({ onOpenFullPage @@ -11,9 +15,12 @@ export function WorkspaceSpaceCompactPanel({ onOpenFullPage: () => void }): React.JSX.Element { const analysis = useAppStore((state) => state.workspaceSpaceAnalysis) + const progress = useAppStore((state) => state.workspaceSpaceScanProgress) const scanError = useAppStore((state) => state.workspaceSpaceScanError) const isScanning = useAppStore((state) => state.workspaceSpaceScanning) const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace) + const cancelWorkspaceSpaceScan = useAppStore((state) => state.cancelWorkspaceSpaceScan) + const progressLabel = getWorkspaceSpaceProgressLabel(progress) const scan = useCallback((): void => { void refreshWorkspaceSpace().catch(() => { @@ -21,6 +28,10 @@ export function WorkspaceSpaceCompactPanel({ }) }, [refreshWorkspaceSpace]) + const cancelScan = useCallback((): void => { + void cancelWorkspaceSpaceScan() + }, [cancelWorkspaceSpaceScan]) + return (
@@ -35,25 +46,45 @@ export function WorkspaceSpaceCompactPanel({
{analysis - ? `${formatBytes(analysis.reclaimableBytes)} reclaimable · ${analysis.scannedWorktreeCount} workspaces` + ? isScanning + ? `${progressLabel ?? 'Scanning workspace sizes'} · last result kept` + : analysis.unavailableWorktreeCount > 0 + ? `${formatBytes(analysis.reclaimableBytes)} reclaimable · ${analysis.unavailableWorktreeCount} unavailable` + : `${formatBytes(analysis.reclaimableBytes)} reclaimable · ${analysis.scannedWorktreeCount} workspaces` : isScanning - ? 'Scanning workspace sizes.' + ? (progressLabel ?? 'Scanning workspace sizes.') : 'Workspace disk usage is not scanned.'}
-
diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index ce8ab9885e1..f7679049360 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -10,10 +10,14 @@ import { GitBranch, HardDrive, Loader2, + Minus, RefreshCw, Search, Server, - Trash2 + Trash2, + ZoomIn, + ZoomOut, + X } from 'lucide-react' import type { WorkspaceSpaceItem, @@ -25,12 +29,20 @@ import { useAppStore } from '../../store' import { runWorktreeBatchDelete } from '../sidebar/delete-worktree-flow' import { Badge } from '../ui/badge' import { Button } from '../ui/button' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger +} from '../ui/context-menu' import { Input } from '../ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { formatBytes, formatCompactCount, getWorkspaceSpaceBranchLabel, + getWorkspaceSpaceProgressLabel, + getWorkspaceSpaceScanDateTimeLabel, getWorkspaceSpaceScanTimeLabel, getWorkspaceSpaceStatusLabel } from './workspace-space-format' @@ -58,28 +70,73 @@ function getTreemapFill(rect: TreemapRect, selected: boolean): string { return TREEMAP_FILLS[rect.index % TREEMAP_FILLS.length] } -function Metric({ label, value }: { label: string; value: string }): React.JSX.Element { +function Metric({ + label, + value, + title +}: { + label: string + value: string + title?: string +}): React.JSX.Element { return (
{label}
-
{value}
+
+ {value} +
) } +function UpdatedMetric({ + scannedAt, + isScanning +}: { + scannedAt: number | null + isScanning: boolean +}): React.JSX.Element { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (scannedAt === null) { + return + } + setNow(Date.now()) + const timer = window.setInterval(() => setNow(Date.now()), 60_000) + return () => window.clearInterval(timer) + }, [scannedAt]) + + return ( + + ) +} + function CheckButton({ checked, disabled, label, onClick }: { - checked: boolean + checked: boolean | 'mixed' disabled?: boolean label: string onClick: () => void }): React.JSX.Element { + const isChecked = checked === true + const isMixed = checked === 'mixed' return ( ) } @@ -137,35 +203,66 @@ function WorkspaceTreemap({ rows, isScanning, selectedWorktreeId, - onSelect + zoomedWorktree, + onSelect, + onZoomChange }: { rows: WorkspaceSpaceWorktree[] isScanning: boolean selectedWorktreeId: string | null + zoomedWorktree: WorkspaceSpaceWorktree | null onSelect: (worktreeId: string) => void + onZoomChange: (worktreeId: string | null) => void }): React.JSX.Element { + const selectedWorktree = rows.find((row) => row.worktreeId === selectedWorktreeId) ?? null + const canZoomSelected = + !!selectedWorktree && + selectedWorktree.status === 'ok' && + selectedWorktree.topLevelItems.length > 0 + const isZoomed = !!zoomedWorktree const rects = useMemo( () => buildTreemapLayout( - rows - .filter((row) => row.status === 'ok' && row.sizeBytes > 0) - .map((row) => ({ - id: row.worktreeId, - label: row.displayName, - sizeBytes: row.sizeBytes - })) + zoomedWorktree + ? zoomedWorktree.topLevelItems + .filter((item) => item.sizeBytes > 0) + .map((item) => ({ + id: item.path, + label: item.name, + sizeBytes: item.sizeBytes + })) + : rows + .filter((row) => row.status === 'ok' && row.sizeBytes > 0) + .map((row) => ({ + id: row.worktreeId, + label: row.displayName, + sizeBytes: row.sizeBytes + })) ), - [rows] + [rows, zoomedWorktree] ) if (rects.length === 0) { return ( -
+
+ {zoomedWorktree ? ( + + ) : null} {isScanning ? : null} {isScanning ? 'Scanning workspace sizes. You can leave this page.' - : 'No scanned workspace sizes yet.'} + : isZoomed + ? 'No top-level items to show.' + : 'No scanned workspace sizes yet.'}
) @@ -173,9 +270,69 @@ function WorkspaceTreemap({ return (
+
+ {zoomedWorktree ? ( + <> +
+ {zoomedWorktree.displayName} +
+ + + ) : canZoomSelected ? ( + + ) : null} +
{rects.map((rect) => { const area = rect.width * rect.height - const selected = rect.id === selectedWorktreeId + const selected = !isZoomed && rect.id === selectedWorktreeId + const rectStyle = { + left: `${rect.x}%`, + top: `${rect.y}%`, + width: `${rect.width}%`, + height: `${rect.height}%`, + background: getTreemapFill(rect, selected) + } + const rectContent = + area >= 80 ? ( + + {rect.label} + {area >= 180 ? ( + + {formatBytes(rect.sizeBytes)} + + ) : null} + + ) : null + + if (isZoomed) { + return ( +
+ {rectContent} +
+ ) + } + return ( ) })} @@ -310,7 +452,8 @@ function WorkspaceRow({ selected, inspected, onToggleSelected, - onInspect + onInspect, + onDelete }: { worktree: WorkspaceSpaceWorktree maxSize: number @@ -318,8 +461,10 @@ function WorkspaceRow({ inspected: boolean onToggleSelected: () => void onInspect: () => void + onDelete: () => void }): React.JSX.Element { - return ( + const canDelete = worktree.canDelete && worktree.status === 'ok' + const row = (
@@ -379,13 +524,31 @@ function WorkspaceRow({
) + + if (!canDelete) { + return row + } + + return ( + + {row} + + + + Delete workspace + + + + ) } export function WorkspaceSpaceManagerPanel(): React.JSX.Element { const analysis = useAppStore((state) => state.workspaceSpaceAnalysis) + const progress = useAppStore((state) => state.workspaceSpaceScanProgress) const scanError = useAppStore((state) => state.workspaceSpaceScanError) const isScanning = useAppStore((state) => state.workspaceSpaceScanning) const refreshWorkspaceSpace = useAppStore((state) => state.refreshWorkspaceSpace) + const cancelWorkspaceSpaceScan = useAppStore((state) => state.cancelWorkspaceSpaceScan) const removeWorkspaceSpaceWorktrees = useAppStore((state) => state.removeWorkspaceSpaceWorktrees) const [query, setQuery] = useState('') const [onlyDeletable, setOnlyDeletable] = useState(false) @@ -393,6 +556,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { const [sortDirection, setSortDirection] = useState('desc') const [selectedIds, setSelectedIds] = useState>(() => new Set()) const [inspectedWorktreeId, setInspectedWorktreeId] = useState(null) + const [treemapZoomWorktreeId, setTreemapZoomWorktreeId] = useState(null) const refresh = useCallback((): void => { void refreshWorkspaceSpace().catch(() => { @@ -400,6 +564,10 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }) }, [refreshWorkspaceSpace]) + const cancelScan = useCallback((): void => { + void cancelWorkspaceSpaceScan() + }, [cancelWorkspaceSpaceScan]) + const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees]) const rows = useMemo( @@ -416,14 +584,37 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { rows.find((row) => row.worktreeId === inspectedWorktreeId) ?? rows.find((row) => row.status === 'ok') ?? null + const zoomedWorktree = + sourceRows.find((row) => row.worktreeId === treemapZoomWorktreeId && row.status === 'ok') ?? + null const maxSize = Math.max(...rows.map((row) => row.sizeBytes), 0) - const selectedDeletableIds = getSelectedDeletableWorkspaceIds(rows, selectedIds) - const visibleDeletableIds = rows - .filter((row) => row.canDelete && row.status === 'ok') - .map((row) => row.worktreeId) + const selectedDeletableIds = useMemo( + () => getSelectedDeletableWorkspaceIds(rows, selectedIds), + [rows, selectedIds] + ) + const selectedDeletableIdSet = useMemo( + () => new Set(selectedDeletableIds), + [selectedDeletableIds] + ) + const visibleDeletableIds = useMemo( + () => rows.filter((row) => row.canDelete && row.status === 'ok').map((row) => row.worktreeId), + [rows] + ) const allVisibleSelected = visibleDeletableIds.length > 0 && visibleDeletableIds.every((id) => selectedIds.has(id)) + const someVisibleSelected = visibleDeletableIds.some((id) => selectedIds.has(id)) + const visibleSelectionState = allVisibleSelected ? true : someVisibleSelected ? 'mixed' : false const isInitialScan = isScanning && !analysis + const hasRows = sourceRows.length > 0 + const progressLabel = getWorkspaceSpaceProgressLabel(progress) + const repoErrors = analysis?.repos.filter((repo) => repo.error !== null) ?? [] + const selectedReclaimableBytes = useMemo( + () => + rows + .filter((row) => selectedDeletableIdSet.has(row.worktreeId)) + .reduce((sum, row) => sum + row.reclaimableBytes, 0), + [rows, selectedDeletableIdSet] + ) useEffect(() => { if (!analysis) { @@ -445,6 +636,14 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }) }, [sourceRows]) + useEffect(() => { + setTreemapZoomWorktreeId((current) => + current && sourceRows.some((row) => row.worktreeId === current && row.status === 'ok') + ? current + : null + ) + }, [sourceRows]) + const toggleSort = (key: WorkspaceSpaceSortKey): void => { if (sortKey === key) { setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) @@ -454,6 +653,11 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { setSortDirection(key === 'name' || key === 'repo' ? 'asc' : 'desc') } + const selectSortKey = (key: WorkspaceSpaceSortKey): void => { + setSortKey(key) + setSortDirection(key === 'name' || key === 'repo' ? 'asc' : 'desc') + } + const toggleSelection = (worktreeId: string): void => { setSelectedIds((current) => { const next = new Set(current) @@ -482,31 +686,45 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { }) } + const deleteWorktrees = useCallback( + (worktreeIds: readonly string[]): void => { + if (worktreeIds.length === 0) { + return + } + runWorktreeBatchDelete(worktreeIds, { + forceConfirm: true, + onDeleted: (deletedIds) => { + removeWorkspaceSpaceWorktrees(deletedIds) + setInspectedWorktreeId((current) => + current && deletedIds.includes(current) ? null : current + ) + setTreemapZoomWorktreeId((current) => + current && deletedIds.includes(current) ? null : current + ) + setSelectedIds((current) => { + if (deletedIds.length === 0) { + return current + } + const next = new Set(current) + for (const id of deletedIds) { + next.delete(id) + } + return next + }) + toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', { + description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.` + }) + } + }) + }, + [removeWorkspaceSpaceWorktrees] + ) + const deleteSelected = (): void => { if (selectedDeletableIds.length === 0) { return } - runWorktreeBatchDelete(selectedDeletableIds, { - onDeleted: (deletedIds) => { - removeWorkspaceSpaceWorktrees(deletedIds) - setInspectedWorktreeId((current) => - current && deletedIds.includes(current) ? null : current - ) - setSelectedIds((current) => { - if (deletedIds.length === 0) { - return current - } - const next = new Set(current) - for (const id of deletedIds) { - next.delete(id) - } - return next - }) - toast.success(deletedIds.length === 1 ? 'Workspace deleted' : 'Workspaces deleted', { - description: `${deletedIds.length} ${deletedIds.length === 1 ? 'workspace' : 'workspaces'} removed from Space.` - }) - } - }) + deleteWorktrees(selectedDeletableIds) } return ( @@ -517,176 +735,264 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { label="Reclaimable" value={analysis ? formatBytes(analysis.reclaimableBytes) : '—'} /> - 0 + ? `${analysis.scannedWorktreeCount}/${analysis.worktreeCount}` + : String(analysis.scannedWorktreeCount) + : '—' } /> +
- + {isScanning ? ( + + ) : ( + + )} {analysis - ? `${formatBytes(analysis.reclaimableBytes)} can be reclaimed from linked worktrees.` + ? isScanning + ? `${progressLabel ?? 'Scanning workspace sizes'}. You can leave this page; the last result stays visible.` + : `${formatBytes(analysis.reclaimableBytes)} can be reclaimed from linked worktrees.` : isScanning - ? 'Scanning workspace sizes in the background. You can leave this page.' + ? `${progressLabel ?? 'Scanning workspace sizes'}. You can leave this page.` : 'Run a scan to inspect workspace sizes.'}
{scanError ? (
- {scanError} + + {scanError} + {analysis ? ' Last successful results remain visible.' : ''} +
) : null} -
- - -
+ {repoErrors.length > 0 ? ( +
+ {repoErrors.map((repo) => ( +
+ + + {repo.displayName}: {repo.error} + +
+ ))} +
+ ) : null} -
-
- - setQuery(event.target.value)} - placeholder="Filter workspaces" - className="pl-9" + {hasRows || isInitialScan ? ( +
+ +
+ ) : null} - - - - - - - -
- -
-
-
- - - -
State
+ {hasRows ? ( +
+
+ + {selectedDeletableIds.length} selected + + · + {formatBytes(selectedReclaimableBytes)} reclaimable +
+
+ + +
+ ) : null} -
- {isInitialScan ? ( -
- - Scanning workspaces. You can leave this page. + {hasRows ? ( +
+
+ + setQuery(event.target.value)} + placeholder="Filter workspaces" + className="pl-9" + /> +
+ + + + + + +
+ ) : null} + + {hasRows || isInitialScan ? ( +
+
+
+
+ +
+ + + +
State
- ) : rows.length === 0 ? ( -
- No matching workspaces. + +
+ {isInitialScan ? ( +
+ + Scanning workspaces. You can leave this page. +
+ ) : rows.length === 0 ? ( +
+ No matching workspaces. +
+ ) : ( + rows.map((worktree) => ( + toggleSelection(worktree.worktreeId)} + onInspect={() => setInspectedWorktreeId(worktree.worktreeId)} + onDelete={() => deleteWorktrees([worktree.worktreeId])} + /> + )) + )}
- ) : ( - rows.map((worktree) => ( - toggleSelection(worktree.worktreeId)} - onInspect={() => setInspectedWorktreeId(worktree.worktreeId)} - /> - )) - )} +
-
+ ) : ( +
+ {scanError + ? 'Scan failed before any workspace sizes were collected.' + : analysis + ? 'No workspace rows were available from the scan.' + : 'Run a scan to inspect workspace sizes.'} +
+ )}
) } diff --git a/src/renderer/src/components/status-bar/workspace-space-format.test.ts b/src/renderer/src/components/status-bar/workspace-space-format.test.ts index 9f239b0e075..6c43b22136c 100644 --- a/src/renderer/src/components/status-bar/workspace-space-format.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-format.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { formatBytes, formatCompactCount, + getWorkspaceSpaceScanTimeLabel, getWorkspaceSpaceStatusLabel } from './workspace-space-format' @@ -18,4 +19,16 @@ describe('workspace space format helpers', () => { expect(formatCompactCount(25_000)).toBe('25k') expect(getWorkspaceSpaceStatusLabel('permission-denied')).toBe('No access') }) + + it('formats scan times as relative age labels', () => { + const now = new Date('2026-05-14T22:15:00Z').getTime() + const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + + expect(getWorkspaceSpaceScanTimeLabel(now - 2 * 60_000, now)).toBe( + formatter.format(-2, 'minute') + ) + expect(getWorkspaceSpaceScanTimeLabel(now - 3 * 60 * 60_000, now)).toBe( + formatter.format(-3, 'hour') + ) + }) }) diff --git a/src/renderer/src/components/status-bar/workspace-space-format.ts b/src/renderer/src/components/status-bar/workspace-space-format.ts index 9566e4e3911..ab056ea68e4 100644 --- a/src/renderer/src/components/status-bar/workspace-space-format.ts +++ b/src/renderer/src/components/status-bar/workspace-space-format.ts @@ -1,9 +1,15 @@ import type { + WorkspaceSpaceScanProgress, WorkspaceSpaceScanStatus, WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const +const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) +const fullDateTimeFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short' +}) export function formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) { @@ -34,8 +40,45 @@ export function formatCompactCount(count: number): string { return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}m` } -export function getWorkspaceSpaceScanTimeLabel(scannedAt: number): string { - return new Date(scannedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) +export function getWorkspaceSpaceScanTimeLabel(scannedAt: number, now = Date.now()): string { + const diffMs = scannedAt - now + const diffMinutes = Math.round(diffMs / 60_000) + if (Math.abs(diffMinutes) < 60) { + return relativeTimeFormatter.format(diffMinutes, 'minute') + } + + const diffHours = Math.round(diffMinutes / 60) + if (Math.abs(diffHours) < 24) { + return relativeTimeFormatter.format(diffHours, 'hour') + } + + const diffDays = Math.round(diffHours / 24) + return relativeTimeFormatter.format(diffDays, 'day') +} + +export function getWorkspaceSpaceScanDateTimeLabel(scannedAt: number): string { + return fullDateTimeFormatter.format(new Date(scannedAt)) +} + +export function getWorkspaceSpaceProgressLabel( + progress: WorkspaceSpaceScanProgress | null +): string | null { + if (!progress) { + return null + } + if (progress.state === 'cancelling') { + return 'Cancelling scan' + } + + const current = + progress.currentWorktreeDisplayName ?? progress.currentRepoDisplayName ?? 'workspaces' + if (progress.totalWorktreeCount > 0) { + return `Scanning ${progress.scannedWorktreeCount} of ${progress.totalWorktreeCount} · ${current}` + } + if (progress.totalRepoCount > 0) { + return `Scanning ${progress.scannedRepoCount} of ${progress.totalRepoCount} repos · ${current}` + } + return 'Scanning workspace sizes' } export function getWorkspaceSpaceStatusLabel(status: WorkspaceSpaceScanStatus): string { diff --git a/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx index 0fed37c3afa..2060ce32724 100644 --- a/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx +++ b/src/renderer/src/components/workspace-space/WorkspaceSpacePage.tsx @@ -9,21 +9,47 @@ export default function WorkspaceSpacePage(): React.JSX.Element { const closeSpacePage = useAppStore((state) => state.closeSpacePage) useEffect(() => { + const hasVisibleOverlay = (): boolean => + Array.from( + document.querySelectorAll('[role="dialog"], [role="listbox"], [role="menu"]') + ).some((element) => { + if (!(element instanceof HTMLElement)) { + return false + } + if (element.closest('[aria-hidden="true"]')) { + return false + } + const style = window.getComputedStyle(element) + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + element.getClientRects().length > 0 + ) + }) + const handleKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Escape' || event.defaultPrevented) { + if (event.key !== 'Escape') { return } // Why: confirmation dialogs own Escape first; page-level Escape should - // only leave the full Space surface when no modal is active. - if (document.querySelector('[role="dialog"]')) { + // only leave the full Space surface when no modal or popover is active. + if (hasVisibleOverlay()) { + return + } + const target = event.target as HTMLElement | null + if ( + target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]') + ) { return } event.preventDefault() closeSpacePage() } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) + // Why: tooltips can consume Escape before bubble listeners see it. Capture + // keeps the first Escape reliable while still deferring to real overlays. + window.addEventListener('keydown', handleKeyDown, { capture: true }) + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) }, [closeSpacePage]) return ( diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index ada47027c8e..238c2b8cb3a 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -92,6 +92,7 @@ export function useIpcEvents(): void { removed ) afterState.purgeWorktreeTerminalState(removed) + afterState.removeWorkspaceSpaceWorktrees(removed) } }) ) @@ -891,6 +892,15 @@ export function useIpcEvents(): void { }) ) + const unsubscribeWorkspaceSpaceProgress = window.api.workspaceSpace?.onProgress?.( + (progress) => { + useAppStore.getState().applyWorkspaceSpaceProgress(progress) + } + ) + if (unsubscribeWorkspaceSpaceProgress) { + unsubs.push(unsubscribeWorkspaceSpaceProgress) + } + // Track SSH connection state changes so the renderer can show // disconnected indicators on remote worktrees. // Why: hydrate initial state for all known targets so worktree cards diff --git a/src/renderer/src/store/slices/workspace-space.ts b/src/renderer/src/store/slices/workspace-space.ts index 2d1bc99c000..f115156ed77 100644 --- a/src/renderer/src/store/slices/workspace-space.ts +++ b/src/renderer/src/store/slices/workspace-space.ts @@ -1,13 +1,19 @@ import type { StateCreator } from 'zustand' -import type { WorkspaceSpaceAnalysis } from '../../../../shared/workspace-space-types' +import type { + WorkspaceSpaceAnalysis, + WorkspaceSpaceScanProgress +} from '../../../../shared/workspace-space-types' import type { AppState } from '../types' let inFlightScan: Promise | null = null export type WorkspaceSpaceSlice = { workspaceSpaceAnalysis: WorkspaceSpaceAnalysis | null + workspaceSpaceScanProgress: WorkspaceSpaceScanProgress | null workspaceSpaceScanError: string | null workspaceSpaceScanning: boolean + applyWorkspaceSpaceProgress: (progress: WorkspaceSpaceScanProgress) => void + cancelWorkspaceSpaceScan: () => Promise refreshWorkspaceSpace: () => Promise removeWorkspaceSpaceWorktrees: (worktreeIds: readonly string[]) => void } @@ -54,27 +60,81 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function isWorkspaceSpaceScanCancelled(error: unknown): boolean { + const message = errorMessage(error).toLowerCase() + return message.includes('workspace space scan cancelled') || message.includes('was cancelled') +} + export const createWorkspaceSpaceSlice: StateCreator = ( set ) => ({ workspaceSpaceAnalysis: null, + workspaceSpaceScanProgress: null, workspaceSpaceScanError: null, workspaceSpaceScanning: false, + applyWorkspaceSpaceProgress: (progress) => + set((state) => { + if ( + state.workspaceSpaceScanProgress?.scanId !== progress.scanId && + !state.workspaceSpaceScanning + ) { + return state + } + return { + workspaceSpaceScanProgress: progress, + workspaceSpaceScanning: true + } + }), + cancelWorkspaceSpaceScan: async () => { + const cancelled = await window.api.workspaceSpace.cancel() + if (cancelled) { + set((state) => + state.workspaceSpaceScanProgress + ? { + workspaceSpaceScanProgress: { + ...state.workspaceSpaceScanProgress, + state: 'cancelling', + updatedAt: Date.now() + } + } + : state + ) + } + return cancelled + }, refreshWorkspaceSpace: async () => { if (inFlightScan) { return inFlightScan } - set({ workspaceSpaceScanning: true, workspaceSpaceScanError: null }) + set({ + workspaceSpaceScanning: true, + workspaceSpaceScanProgress: null, + workspaceSpaceScanError: null + }) // Why: the compact Resource Manager card and the full Space page share // one manual scan result; duplicate button presses should join the same IO. inFlightScan = window.api.workspaceSpace .analyze() - .then((analysis) => { - set({ workspaceSpaceAnalysis: analysis, workspaceSpaceScanning: false }) + .then((result) => { + if (!result.ok) { + throw new Error('Workspace space scan cancelled') + } + const analysis = result.analysis + set({ + workspaceSpaceAnalysis: analysis, + workspaceSpaceScanning: false, + workspaceSpaceScanProgress: null + }) return analysis }) .catch((error: unknown) => { - set({ workspaceSpaceScanError: errorMessage(error), workspaceSpaceScanning: false }) + set({ + workspaceSpaceScanError: isWorkspaceSpaceScanCancelled(error) + ? null + : errorMessage(error), + workspaceSpaceScanning: false, + workspaceSpaceScanProgress: null + }) throw error }) .finally(() => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 32b2606864f..2493e78f45e 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -580,9 +580,12 @@ export const createWorktreeSlice: StateCreator sortEpoch: s.sortEpoch + 1 } }) + get().removeWorkspaceSpaceWorktrees?.([worktreeId]) return { ok: true as const } } catch (err) { - console.error('Failed to remove worktree:', err) + // Why: git refusing a non-force delete for dirty/untracked files is a + // handled user decision point surfaced by the delete toast, not an app error. + console.warn('Failed to remove worktree:', err) const error = err instanceof Error ? err.message : String(err) set((s) => ({ deleteStateByWorktreeId: { diff --git a/src/shared/workspace-space-compaction.ts b/src/shared/workspace-space-compaction.ts new file mode 100644 index 00000000000..9b3ab739e92 --- /dev/null +++ b/src/shared/workspace-space-compaction.ts @@ -0,0 +1,41 @@ +import type { WorkspaceSpaceItem } from './workspace-space-types' + +export const WORKSPACE_SPACE_MAX_TOP_LEVEL_ITEMS = 48 + +export function compactWorkspaceSpaceItems(items: WorkspaceSpaceItem[]): { + topLevelItems: WorkspaceSpaceItem[] + omittedTopLevelItemCount: number + omittedTopLevelSizeBytes: number +} { + const sorted = [...items].sort( + (a, b) => b.sizeBytes - a.sizeBytes || a.name.localeCompare(b.name) + ) + if (sorted.length <= WORKSPACE_SPACE_MAX_TOP_LEVEL_ITEMS) { + return { + topLevelItems: sorted, + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } + } + + const visible = sorted.slice(0, WORKSPACE_SPACE_MAX_TOP_LEVEL_ITEMS - 1) + const omitted = sorted.slice(WORKSPACE_SPACE_MAX_TOP_LEVEL_ITEMS - 1) + const other = omitted.reduce( + (acc, item) => ({ + ...acc, + sizeBytes: acc.sizeBytes + item.sizeBytes + }), + { + name: 'Other', + path: '', + kind: 'other', + sizeBytes: 0 + } + ) + + return { + topLevelItems: [...visible, other], + omittedTopLevelItemCount: omitted.length, + omittedTopLevelSizeBytes: other.sizeBytes + } +} diff --git a/src/shared/workspace-space-types.ts b/src/shared/workspace-space-types.ts index 9913ec87200..bfd173db4eb 100644 --- a/src/shared/workspace-space-types.ts +++ b/src/shared/workspace-space-types.ts @@ -61,3 +61,28 @@ export type WorkspaceSpaceAnalysis = { repos: WorkspaceSpaceRepoSummary[] worktrees: WorkspaceSpaceWorktree[] } + +export type WorkspaceSpaceAnalyzeResult = + | { ok: true; analysis: WorkspaceSpaceAnalysis } + | { ok: false; cancelled: true } + +export type WorkspaceSpaceDirectoryScanResult = { + sizeBytes: number + skippedEntryCount: number + topLevelItems: WorkspaceSpaceItem[] + omittedTopLevelItemCount: number + omittedTopLevelSizeBytes: number +} + +export type WorkspaceSpaceScanProgress = { + scanId: string + state: 'running' | 'cancelling' + startedAt: number + updatedAt: number + totalRepoCount: number + scannedRepoCount: number + totalWorktreeCount: number + scannedWorktreeCount: number + currentRepoDisplayName: string | null + currentWorktreeDisplayName: string | null +}