Add Space manager V2 safeguards
This commit is contained in:
parent
dc83da7036
commit
2d44ee83b4
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 ?? {})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue