Add Space manager V2 safeguards

This commit is contained in:
Neil 2026-05-14 23:30:22 -07:00 committed by GitHub
parent dc83da7036
commit 2d44ee83b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2276 additions and 401 deletions

View File

@ -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<string, () => Promise<WorkspaceSpaceAnalysis>>(),
analyzeWorkspaceSpaceMock: vi.fn(),
removeHandlerMock: vi.fn(),
handleMock: vi.fn((channel: string, handler: () => Promise<WorkspaceSpaceAnalysis>) => {
handlers.set(channel, handler)
})
}))
const {
handlers,
analyzeWorkspaceSpaceMock,
removeHandlerMock,
handleMock,
WorkspaceSpaceScanCancelledErrorMock
} = vi.hoisted(() => {
const handlers = new Map<string, (...args: unknown[]) => Promise<unknown>>()
return {
handlers,
analyzeWorkspaceSpaceMock: vi.fn(),
removeHandlerMock: vi.fn(),
handleMock: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
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<WorkspaceSpaceAnalysis>(() => {})
})
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 })
})
})

View File

@ -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<WorkspaceSpaceAnalyzeResult>
}
export function registerWorkspaceSpaceHandlers(store: Store): void {
let inFlightScan: Promise<WorkspaceSpaceAnalysis> | null = null
let inFlightScan: InFlightWorkspaceSpaceScan | null = null
ipcMain.removeHandler('workspaceSpace:cancel')
ipcMain.removeHandler('workspaceSpace:analyze')
ipcMain.handle('workspaceSpace:analyze', async (): Promise<WorkspaceSpaceAnalysis> => {
ipcMain.handle('workspaceSpace:analyze', async (event): Promise<WorkspaceSpaceAnalyzeResult> => {
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<boolean> => {
if (!inFlightScan || inFlightScan.controller.signal.aborted) {
return false
}
inFlightScan.controller.abort()
inFlightScan.progress = {
...inFlightScan.progress,
state: 'cancelling',
updatedAt: Date.now()
}
return true
})
}

View File

@ -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 () => {

View File

@ -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/<name>`) 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)

View File

@ -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' })

View File

@ -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<SFTPWrapper>
@ -12,6 +13,8 @@ type WatchRegistration = {
setupPromise: Promise<void>
}
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<WorkspaceSpaceDirectoryScanResult> {
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<void> {
await this.mux.request('fs.deletePath', { targetPath, recursive })
}

View File

@ -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)
})

View File

@ -123,10 +123,17 @@ export class SshGitProvider implements IGitProvider {
})) as GitDiffResult[]
}
async listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]> {
return (await this.mux.request('git.listWorktrees', {
repoPath
})) as GitWorktreeInfo[]
async listWorktrees(
repoPath: string,
options?: { signal?: AbortSignal }
): Promise<GitWorktreeInfo[]> {
return (await this.mux.request(
'git.listWorktrees',
{
repoPath
},
{ signal: options?.signal }
)) as GitWorktreeInfo[]
}
async addWorktree(

View File

@ -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<string>
search(opts: SearchOptions): Promise<SearchResult>
listFiles(rootPath: string, options?: { excludePaths?: string[] }): Promise<string[]>
scanWorkspaceSpace?(
rootPath: string,
options?: { signal?: AbortSignal }
): Promise<WorkspaceSpaceDirectoryScanResult>
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<GitDiffResult[]>
listWorktrees(repoPath: string): Promise<GitWorktreeInfo[]>
listWorktrees(repoPath: string, options?: { signal?: AbortSignal }): Promise<GitWorktreeInfo[]>
addWorktree(
repoPath: string,
branchName: string,

View File

@ -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/<name>`) is still intact. Without pruning, `git worktree
// list` continues to show the stale entry and the branch it had checked out

View File

@ -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 () => {

View File

@ -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<typeof setTimeout>
cleanup: () => void
}
export type NotificationHandler = (method: string, params: Record<string, unknown>) => void
@ -127,10 +130,19 @@ export class SshChannelMultiplexer {
/**
* Send a JSON-RPC request and wait for the response.
*/
async request(method: string, params?: Record<string, unknown>): Promise<unknown> {
async request(
method: string,
params?: Record<string, unknown>,
options?: { signal?: AbortSignal; timeoutMs?: number }
): Promise<unknown> {
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<typeof setTimeout>
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) {

View File

@ -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',

View File

@ -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<void> => {
throwIfAborted(signal)
if (active < maxConcurrent) {
active += 1
return
}
await new Promise<void>((resolve) => queue.push(resolve))
await new Promise<void>((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<string, number> {
const sizes = new Map<string, number>()
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<string, number> {
return sizes
}
async function readLocalDuDepthOne(rootPath: string): Promise<Map<string, number>> {
async function readLocalDuDepthOne(
rootPath: string,
signal?: AbortSignal
): Promise<Map<string, number>> {
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<WorkspaceSpaceItem>(
(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<ScanStats> {
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<ScanStats | null> => {
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<ScanStats> {
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<string, number>,
limit: AsyncLimiter
limit: AsyncLimiter,
signal?: AbortSignal
): Promise<ScanStats> {
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<WorkspaceSpaceWorktree> {
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<ScanStats | null> => {
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<WorkspaceSpaceWorktree> {
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<WorkspaceSpaceWorktree> {
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<WorkspaceSpaceWorktree> {
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<WorktreeListResult> {
async function listWorktreesForSpaceScan(
repo: Repo,
signal?: AbortSignal
): Promise<WorktreeListResult> {
try {
throwIfAborted(signal)
if (isFolderRepo(repo)) {
return { ok: true, worktrees: [createFolderWorktree(repo)] }
}
@ -593,10 +728,17 @@ async function listWorktreesForSpaceScan(repo: Repo): Promise<WorktreeListResult
error: `SSH connection "${repo.connectionId}" is not connected.`
}
}
return { ok: true, worktrees: await provider.listWorktrees(repo.path) }
const worktrees = await provider.listWorktrees(repo.path, { signal })
throwIfAborted(signal)
return { ok: true, worktrees }
}
return { ok: true, worktrees: await listRepoWorktrees(repo) }
const worktrees = await listRepoWorktrees(repo)
throwIfAborted(signal)
return { ok: true, worktrees }
} catch (error) {
if (error instanceof WorkspaceSpaceScanCancelledError) {
throw error
}
const classified = classifyError(error)
return { ok: false, status: classified.status, error: classified.message }
}
@ -607,9 +749,38 @@ function mergeForSpaceScan(repo: Repo, gitWorktree: GitWorktreeInfo, store: Stor
return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName)
}
async function scanRepo(repo: Repo, scannedAt: number, store: Store): Promise<RepoScanResult> {
const listed = await listWorktreesForSpaceScan(repo)
function reportProgress(
progress: WorkspaceSpaceProgressState,
updates: Partial<WorkspaceSpaceProgressState>,
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<RepoScanResult> {
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<Re
const worktrees = listed.worktrees.map((gitWorktree) =>
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<Re
}
}
export async function analyzeWorkspaceSpace(store: Store): Promise<WorkspaceSpaceAnalysis> {
export async function analyzeWorkspaceSpace(
store: Store,
options: WorkspaceSpaceAnalyzeOptions = {}
): Promise<WorkspaceSpaceAnalysis> {
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)

View File

@ -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<boolean> {
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
}
}

View File

@ -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<WorkspaceSpaceAnalysis>
analyze: () => Promise<WorkspaceSpaceAnalyzeResult>
cancel: () => Promise<boolean>
onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void
}
pty: {
spawn: (opts: {

View File

@ -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<WorkspaceSpaceAnalysis> => ipcRenderer.invoke('workspaceSpace:analyze')
analyze: (): Promise<WorkspaceSpaceAnalyzeResult> =>
ipcRenderer.invoke('workspaceSpace:analyze'),
cancel: (): Promise<boolean> => 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: {

View File

@ -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)
})
})

View File

@ -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<string, MethodHandler>()
private notificationHandlers = new Map<string, NotificationHandler>()
private requestAbortControllers = new Map<number, AbortController>()
private nextOutgoingSeq = 1
private highestReceivedSeq = 0
private keepaliveTimer: ReturnType<typeof setInterval> | 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 ?? {})

View File

@ -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')

View File

@ -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<string, unknown>, context: RequestContext) {
const rootPath = expandTilde(params.rootPath as string)
return scanWorkspaceSpaceDirectory(rootPath, context)
}
private async watch(params: Record<string, unknown>, context?: RequestContext) {
const rootPath = expandTilde(params.rootPath as string)

View File

@ -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 = <T>(task: () => Promise<T>) => Promise<T>
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<void> => {
throwIfCancelled(context)
if (active < maxConcurrent) {
active += 1
return
}
await new Promise<void>((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 <T>(task: () => Promise<T>): Promise<T> => {
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<string, number> {
const sizes = new Map<string, number>()
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<Map<string, number>> {
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<string, number>,
limit: AsyncLimiter,
context: RequestContext
): Promise<ScanStats> {
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<ScanStats> {
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<ScanStats | null> => {
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<WorkspaceSpaceDirectoryScanResult> {
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<ScanStats | null> => {
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<WorkspaceSpaceDirectoryScanResult> {
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<ScanStats | null> => {
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<WorkspaceSpaceDirectoryScanResult> {
if (platform !== 'win32') {
try {
return await scanDirectoryWithDu(rootPath, context)
} catch (error) {
if (error instanceof RelayWorkspaceSpaceScanCancelledError) {
throw error
}
}
}
return scanDirectoryWithNode(rootPath, context)
}

View File

@ -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<HTMLButtonElement>(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() {
</div>
)}
{!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.

View File

@ -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
})
})

View File

@ -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<Worktree, 'id' | 'displayName'>[]
): Promise<string[]> {
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

View File

@ -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 (
<div className="border-t border-border/50 bg-muted/15 px-3 py-2">
<div className="flex items-center justify-between gap-2">
@ -35,25 +46,45 @@ export function WorkspaceSpaceCompactPanel({
</div>
<div className="truncate text-[11px] text-muted-foreground">
{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.'}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button variant="outline" size="xs" onClick={scan} disabled={isScanning}>
<Button
variant="outline"
size="xs"
onClick={isScanning ? cancelScan : scan}
disabled={progress?.state === 'cancelling'}
className="w-24"
>
{isScanning ? (
<Loader2 className="size-3 animate-spin" />
progress?.state === 'cancelling' ? (
<Loader2 className="size-3 animate-spin" />
) : (
<X className="size-3" />
)
) : (
<RefreshCw className="size-3" />
)}
{isScanning ? 'Scanning' : analysis ? 'Refresh' : 'Scan'}
{isScanning
? progress?.state === 'cancelling'
? 'Stopping'
: 'Cancel'
: analysis
? 'Refresh'
: 'Scan'}
</Button>
<Button variant="ghost" size="xs" onClick={onOpenFullPage}>
Open
Review
</Button>
</div>
</div>

View File

@ -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 (
<div className="min-w-0 px-4 py-3">
<div className="truncate text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{label}
</div>
<div className="mt-1 truncate text-lg font-semibold tabular-nums">{value}</div>
<div className="mt-1 truncate text-lg font-semibold tabular-nums" title={title}>
{value}
</div>
</div>
)
}
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 (
<Metric
label="Updated"
title={scannedAt === null ? undefined : getWorkspaceSpaceScanDateTimeLabel(scannedAt)}
value={
scannedAt === null
? isScanning
? 'Scanning'
: '—'
: getWorkspaceSpaceScanTimeLabel(scannedAt, now)
}
/>
)
}
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 (
<button
type="button"
@ -87,19 +144,28 @@ function CheckButton({
aria-checked={checked}
aria-label={label}
disabled={disabled}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
checked
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground/50 bg-background/40 text-transparent',
'flex size-6 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
disabled && 'cursor-default opacity-35'
)}
>
{checked ? <Check className="size-3" strokeWidth={3} /> : null}
<span
className={cn(
'flex size-4 items-center justify-center rounded-sm border transition-colors',
isChecked || isMixed
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground/50 bg-background/40 text-transparent'
)}
>
{isChecked ? <Check className="size-3" strokeWidth={3} /> : null}
{isMixed ? <Minus className="size-3" strokeWidth={3} /> : null}
</span>
</button>
)
}
@ -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 (
<div className="flex h-72 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 text-sm text-muted-foreground">
<div className="relative flex h-72 items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 text-sm text-muted-foreground">
{zoomedWorktree ? (
<Button
variant="outline"
size="xs"
onClick={() => onZoomChange(null)}
className="absolute right-2 top-2 gap-1.5 bg-background/90 px-2.5 backdrop-blur"
>
<ZoomOut className="size-3" />
All
</Button>
) : null}
<span className="flex items-center gap-2">
{isScanning ? <Loader2 className="size-4 animate-spin" /> : 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.'}
</span>
</div>
)
@ -173,9 +270,69 @@ function WorkspaceTreemap({
return (
<div className="relative h-72 overflow-hidden rounded-lg border border-border/70 bg-muted/20">
<div className="absolute right-2 top-2 z-10 flex max-w-[calc(100%-1rem)] items-center gap-2">
{zoomedWorktree ? (
<>
<div className="max-w-56 truncate rounded-md border border-border/70 bg-background/90 px-2 py-1 text-[11px] font-medium shadow-xs backdrop-blur">
{zoomedWorktree.displayName}
</div>
<Button
variant="outline"
size="xs"
onClick={() => onZoomChange(null)}
className="gap-1.5 bg-background/90 px-2.5 backdrop-blur"
>
<ZoomOut className="size-3" />
All
</Button>
</>
) : canZoomSelected ? (
<Button
variant="outline"
size="xs"
onClick={() => onZoomChange(selectedWorktree.worktreeId)}
className="gap-1.5 bg-background/90 px-2.5 backdrop-blur"
>
<ZoomIn className="size-3" />
Zoom
</Button>
) : null}
</div>
{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 ? (
<span className="block min-w-0 text-[11px] font-medium leading-tight text-foreground">
<span className="block truncate">{rect.label}</span>
{area >= 180 ? (
<span className="mt-0.5 block truncate text-muted-foreground">
{formatBytes(rect.sizeBytes)}
</span>
) : null}
</span>
) : null
if (isZoomed) {
return (
<div
key={rect.id}
title={`${rect.label}${formatBytes(rect.sizeBytes)}`}
className="absolute overflow-hidden border border-background/80 p-2 text-left"
style={rectStyle}
>
{rectContent}
</div>
)
}
return (
<button
key={rect.id}
@ -187,24 +344,9 @@ function WorkspaceTreemap({
'absolute overflow-hidden border border-background/80 p-2 text-left transition-[filter,outline] hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected && 'ring-2 ring-ring ring-offset-1 ring-offset-background'
)}
style={{
left: `${rect.x}%`,
top: `${rect.y}%`,
width: `${rect.width}%`,
height: `${rect.height}%`,
background: getTreemapFill(rect, selected)
}}
style={rectStyle}
>
{area >= 80 ? (
<span className="block min-w-0 text-[11px] font-medium leading-tight text-foreground">
<span className="block truncate">{rect.label}</span>
{area >= 180 ? (
<span className="mt-0.5 block truncate text-muted-foreground">
{formatBytes(rect.sizeBytes)}
</span>
) : null}
</span>
) : null}
{rectContent}
</button>
)
})}
@ -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 = (
<div
role="button"
tabIndex={0}
@ -338,7 +483,7 @@ function WorkspaceRow({
>
<CheckButton
checked={selected}
disabled={!worktree.canDelete || worktree.status !== 'ok'}
disabled={!canDelete}
label={`Select ${worktree.displayName}`}
onClick={onToggleSelected}
/>
@ -379,13 +524,31 @@ function WorkspaceRow({
</div>
</div>
)
if (!canDelete) {
return row
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>{row}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem variant="destructive" onSelect={onDelete}>
<Trash2 className="size-3.5" />
Delete workspace
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
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<WorkspaceSpaceSortDirection>('desc')
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
const [inspectedWorktreeId, setInspectedWorktreeId] = useState<string | null>(null)
const [treemapZoomWorktreeId, setTreemapZoomWorktreeId] = useState<string | null>(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) : '—'}
/>
<Metric label="Workspaces" value={analysis ? String(analysis.scannedWorktreeCount) : '—'} />
<Metric
label="Updated"
label="Workspaces"
value={
analysis
? getWorkspaceSpaceScanTimeLabel(analysis.scannedAt)
: isScanning
? 'Scanning'
: '—'
? analysis.unavailableWorktreeCount > 0
? `${analysis.scannedWorktreeCount}/${analysis.worktreeCount}`
: String(analysis.scannedWorktreeCount)
: '—'
}
/>
<UpdatedMetric scannedAt={analysis?.scannedAt ?? null} isScanning={isScanning} />
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="size-4 shrink-0" />
{isScanning ? (
<Loader2 className="size-4 shrink-0 animate-spin" />
) : (
<HardDrive className="size-4 shrink-0" />
)}
<span className="truncate">
{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.'}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={isScanning}
onClick={isScanning ? cancelScan : refresh}
disabled={progress?.state === 'cancelling'}
className="w-28 gap-1.5"
>
{isScanning ? (
<Loader2 className="size-3.5 animate-spin" />
progress?.state === 'cancelling' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<X className="size-3.5" />
)
) : (
<RefreshCw className="size-3.5" />
)}
{isScanning ? 'Scanning' : analysis ? 'Refresh' : 'Scan'}
{isScanning
? progress?.state === 'cancelling'
? 'Stopping'
: 'Cancel'
: analysis
? 'Refresh'
: 'Scan'}
</Button>
</div>
{scanError ? (
<div className="flex items-start gap-2 rounded-md border border-destructive/35 bg-destructive/8 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">{scanError}</span>
<span className="min-w-0 break-words">
{scanError}
{analysis ? ' Last successful results remain visible.' : ''}
</span>
</div>
) : null}
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(20rem,0.6fr)]">
<WorkspaceTreemap
rows={sourceRows}
isScanning={isInitialScan}
selectedWorktreeId={inspectedWorktree?.worktreeId ?? null}
onSelect={setInspectedWorktreeId}
/>
<BreakdownList worktree={inspectedWorktree} isScanning={isInitialScan} />
</div>
{repoErrors.length > 0 ? (
<div className="space-y-1.5 rounded-md border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{repoErrors.map((repo) => (
<div key={repo.repoId} className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">
{repo.displayName}: {repo.error}
</span>
</div>
))}
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[16rem] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter workspaces"
className="pl-9"
{hasRows || isInitialScan ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(20rem,0.6fr)]">
<WorkspaceTreemap
rows={sourceRows}
isScanning={isInitialScan}
selectedWorktreeId={inspectedWorktree?.worktreeId ?? null}
zoomedWorktree={zoomedWorktree}
onSelect={setInspectedWorktreeId}
onZoomChange={setTreemapZoomWorktreeId}
/>
<BreakdownList worktree={inspectedWorktree} isScanning={isInitialScan} />
</div>
) : null}
<Select
value={sortKey}
onValueChange={(value) => setSortKey(value as WorkspaceSpaceSortKey)}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="size">Size</SelectItem>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="repo">Repository</SelectItem>
<SelectItem value="activity">Activity</SelectItem>
</SelectContent>
</Select>
<Button
variant={onlyDeletable ? 'secondary' : 'outline'}
size="sm"
onClick={() => setOnlyDeletable((current) => !current)}
className="w-32"
>
{onlyDeletable ? 'Deletable' : 'All'}
</Button>
<Button
variant="outline"
size="sm"
onClick={toggleVisibleSelection}
disabled={visibleDeletableIds.length === 0}
className="w-32 gap-1.5"
>
<Check className="size-3.5" />
{allVisibleSelected ? 'Clear' : 'Select'}
</Button>
<Button
variant="destructive"
size="sm"
onClick={deleteSelected}
disabled={selectedDeletableIds.length === 0}
className="w-32 gap-1.5"
>
<Trash2 className="size-3.5" />
{selectedDeletableIds.length > 0 ? `Delete ${selectedDeletableIds.length}` : 'Delete'}
</Button>
</div>
<div className="overflow-hidden rounded-lg border border-border/70 bg-background/30">
<div className="grid grid-cols-[1.75rem_minmax(0,1.35fr)_minmax(9rem,0.65fr)_8rem_6rem] gap-3 border-b border-border/60 px-3 py-2 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
<div />
<button
type="button"
onClick={() => toggleSort('name')}
className="flex items-center gap-1 text-left"
>
Workspace
<SortIndicator sortKey="name" activeKey={sortKey} direction={sortDirection} />
</button>
<button
type="button"
onClick={() => toggleSort('repo')}
className="flex items-center gap-1 text-left"
>
Repository
<SortIndicator sortKey="repo" activeKey={sortKey} direction={sortDirection} />
</button>
<button
type="button"
onClick={() => toggleSort('size')}
className="flex items-center justify-end gap-1 text-right"
>
Size
<SortIndicator sortKey="size" activeKey={sortKey} direction={sortDirection} />
</button>
<div className="text-right">State</div>
{hasRows ? (
<div className="sticky top-0 z-10 -mx-1 flex flex-wrap items-center justify-between gap-2 rounded-md border border-border/70 bg-background/95 px-3 py-2 shadow-xs backdrop-blur">
<div className="min-w-0 text-xs text-muted-foreground">
<span className="font-medium text-foreground">
{selectedDeletableIds.length} selected
</span>
<span className="mx-1.5">·</span>
<span>{formatBytes(selectedReclaimableBytes)} reclaimable</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedIds(new Set<string>())}
disabled={selectedDeletableIds.length === 0}
className="!px-3"
>
Clear
</Button>
<Button
variant="destructive"
size="sm"
onClick={deleteSelected}
disabled={selectedDeletableIds.length === 0}
className="min-w-[9.5rem] gap-1.5 !px-3.5"
>
<Trash2 className="size-3.5" />
Delete selected
</Button>
</div>
</div>
) : null}
<div className="max-h-[28rem] overflow-y-auto scrollbar-sleek">
{isInitialScan ? (
<div className="flex items-center justify-center gap-2 px-4 py-10 text-center text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Scanning workspaces. You can leave this page.
{hasRows ? (
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[16rem] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Filter workspaces"
className="pl-9"
/>
</div>
<Select
value={sortKey}
onValueChange={(value) => selectSortKey(value as WorkspaceSpaceSortKey)}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="size">Size</SelectItem>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="repo">Repository</SelectItem>
<SelectItem value="activity">Activity</SelectItem>
</SelectContent>
</Select>
<Button
variant={onlyDeletable ? 'secondary' : 'outline'}
size="sm"
onClick={() => setOnlyDeletable((current) => !current)}
className="w-32"
aria-label="Show only deletable workspaces"
>
{onlyDeletable ? 'Deletable' : 'All'}
</Button>
<Button
variant="outline"
size="sm"
onClick={toggleVisibleSelection}
disabled={visibleDeletableIds.length === 0}
className="w-32 gap-1.5"
aria-label={
allVisibleSelected ? 'Clear visible selection' : 'Select visible deletable workspaces'
}
>
<Check className="size-3.5" />
{allVisibleSelected ? 'Clear' : 'Select'}
</Button>
</div>
) : null}
{hasRows || isInitialScan ? (
<div className="overflow-x-auto rounded-lg border border-border/70 bg-background/30">
<div className="min-w-[46rem]">
<div className="grid grid-cols-[1.75rem_minmax(0,1.35fr)_minmax(9rem,0.65fr)_8rem_6rem] gap-3 border-b border-border/60 px-3 py-2 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
<div className="flex items-center">
<CheckButton
checked={visibleSelectionState}
disabled={visibleDeletableIds.length === 0}
label={
allVisibleSelected
? 'Clear visible selection'
: 'Select visible deletable workspaces'
}
onClick={toggleVisibleSelection}
/>
</div>
<button
type="button"
onClick={() => toggleSort('name')}
className="flex items-center gap-1 text-left"
>
Workspace
<SortIndicator sortKey="name" activeKey={sortKey} direction={sortDirection} />
</button>
<button
type="button"
onClick={() => toggleSort('repo')}
className="flex items-center gap-1 text-left"
>
Repository
<SortIndicator sortKey="repo" activeKey={sortKey} direction={sortDirection} />
</button>
<button
type="button"
onClick={() => toggleSort('size')}
className="flex items-center justify-end gap-1 text-right"
>
Size
<SortIndicator sortKey="size" activeKey={sortKey} direction={sortDirection} />
</button>
<div className="text-right">State</div>
</div>
) : rows.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
No matching workspaces.
<div className="max-h-[28rem] overflow-y-auto scrollbar-sleek">
{isInitialScan ? (
<div className="flex items-center justify-center gap-2 px-4 py-10 text-center text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Scanning workspaces. You can leave this page.
</div>
) : rows.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
No matching workspaces.
</div>
) : (
rows.map((worktree) => (
<WorkspaceRow
key={worktree.worktreeId}
worktree={worktree}
maxSize={maxSize}
selected={selectedIds.has(worktree.worktreeId)}
inspected={inspectedWorktree?.worktreeId === worktree.worktreeId}
onToggleSelected={() => toggleSelection(worktree.worktreeId)}
onInspect={() => setInspectedWorktreeId(worktree.worktreeId)}
onDelete={() => deleteWorktrees([worktree.worktreeId])}
/>
))
)}
</div>
) : (
rows.map((worktree) => (
<WorkspaceRow
key={worktree.worktreeId}
worktree={worktree}
maxSize={maxSize}
selected={selectedIds.has(worktree.worktreeId)}
inspected={inspectedWorktree?.worktreeId === worktree.worktreeId}
onToggleSelected={() => toggleSelection(worktree.worktreeId)}
onInspect={() => setInspectedWorktreeId(worktree.worktreeId)}
/>
))
)}
</div>
</div>
</div>
) : (
<div className="rounded-lg border border-border/70 bg-background/30 px-4 py-10 text-center text-sm text-muted-foreground">
{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.'}
</div>
)}
</div>
)
}

View File

@ -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')
)
})
})

View File

@ -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 {

View File

@ -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 (

View File

@ -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

View File

@ -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<WorkspaceSpaceAnalysis> | null = null
export type WorkspaceSpaceSlice = {
workspaceSpaceAnalysis: WorkspaceSpaceAnalysis | null
workspaceSpaceScanProgress: WorkspaceSpaceScanProgress | null
workspaceSpaceScanError: string | null
workspaceSpaceScanning: boolean
applyWorkspaceSpaceProgress: (progress: WorkspaceSpaceScanProgress) => void
cancelWorkspaceSpaceScan: () => Promise<boolean>
refreshWorkspaceSpace: () => Promise<WorkspaceSpaceAnalysis>
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<AppState, [], [], WorkspaceSpaceSlice> = (
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(() => {

View File

@ -580,9 +580,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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: {

View File

@ -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<WorkspaceSpaceItem>(
(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
}
}

View File

@ -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
}