feat(space): open disk usage in a separate window (#29513)

* feat(space): open disk usage in a separate window

* perf(space): move local disk scan off Electron main thread

* fix(space): satisfy full PR quality gates
This commit is contained in:
Neil
2026-10-16 01:25:19 -07:00
committed by GitHub
parent c4f1dedad4
commit 7f8faae800
48 changed files with 1202 additions and 185 deletions
@@ -32,6 +32,7 @@ export const MAIN_BUILD_ENTRY_POINTS: Record<string, string> = {
// corpora and read SQLite synchronously; a worker thread keeps that
// off the main-process event loop.
'usage-scan-worker-entry': resolve('src/main/usage/usage-scan-worker-entry.ts'),
'workspace-space-scan-worker-entry': resolve('src/main/workspace-space-scan-worker-entry.ts'),
// Why: a first account setup can merge a large history tree with sync fs calls.
'claude-profile-setup-worker-entry': resolve(
'src/main/claude-accounts/claude-profile-setup-worker-entry.ts'
@@ -64,6 +64,7 @@ const WORKER_THREAD_ENTRY_NAMES = [
'port-scan-command-worker-entry',
'worker-thread-child-process-entry',
'usage-scan-worker-entry',
'workspace-space-scan-worker-entry',
'claude-profile-setup-worker-entry',
'profile-state-backup-worker-entry',
'profile-state-writer-worker-entry'
@@ -111,7 +111,6 @@ src/main/ssh/system-ssh-operation-lifecycle.ts
src/main/startup/ensure-virtual-display.ts
src/main/startup/hydrate-shell-path.ts
src/main/windows-process-tree-kill.ts
src/main/workspace-space-analysis.ts
src/main/zsh-startup-hook-pty-harness.ts
src/shared/ephemeral-vm-recipe-process.ts
src/shared/ephemeral-vm-recipe-runner.ts
+2 -3
View File
@@ -95,10 +95,8 @@ function createStartupDiagnosticsBanner(chunkName: string): string {
return '"<unserializable>"'
}
}
let closeSync
let diagnosticFileDescriptor
let openSync
let writeSync
let closeSync, openSync, writeSync
try {
const fs = require('node:fs')
closeSync = fs.closeSync
@@ -335,6 +333,7 @@ export const electronViteConfig: UserConfig = {
input: {
index: resolve('src/renderer/index.html'),
popout: resolve('src/renderer/popout.html'),
space: resolve('src/renderer/space.html'),
web: resolve('src/renderer/web-index.html')
}
}
@@ -59,6 +59,7 @@ const {
registerCodexConfigSyncHandlersMock,
registerOnboardingHandlersMock,
registerDashboardPopoutHandlersMock,
registerWorkspaceSpaceWindowHandlersMock,
isDashboardPopoutRendererMock,
registerTerminalPreviewHandlersMock,
registerSpeechHandlersMock,
@@ -129,6 +130,7 @@ const {
registerCodexConfigSyncHandlersMock: vi.fn(),
registerOnboardingHandlersMock: vi.fn(),
registerDashboardPopoutHandlersMock: vi.fn(),
registerWorkspaceSpaceWindowHandlersMock: vi.fn(),
isDashboardPopoutRendererMock: vi.fn(),
registerTerminalPreviewHandlersMock: vi.fn(),
registerSpeechHandlersMock: vi.fn(),
@@ -169,6 +171,10 @@ vi.mock('../dashboard-popout', () => ({
registerDashboardPopoutHandlers: registerDashboardPopoutHandlersMock
}))
vi.mock('../workspace-space-window', () => ({
registerWorkspaceSpaceWindowHandlers: registerWorkspaceSpaceWindowHandlersMock
}))
vi.mock('../../window/dashboard-popout-window', () => ({
isDashboardPopoutRenderer: isDashboardPopoutRendererMock
}))
@@ -489,6 +495,7 @@ describe('registerCoreHandlers', () => {
registerHostedReviewHandlersMock.mockReset()
registerExportHandlersMock.mockReset()
registerDashboardPopoutHandlersMock.mockReset()
registerWorkspaceSpaceWindowHandlersMock.mockReset()
registerTerminalPreviewHandlersMock.mockReset()
registerSpeechHandlersMock.mockReset()
registerSkillsHandlersMock.mockReset()
@@ -599,6 +606,7 @@ describe('registerCoreHandlers', () => {
expect(registerDeveloperPermissionHandlersMock).toHaveBeenCalled()
expect(registerComputerUsePermissionHandlersMock).toHaveBeenCalled()
expect(registerDashboardPopoutHandlersMock).toHaveBeenCalledWith(store, undefined)
expect(registerWorkspaceSpaceWindowHandlersMock).toHaveBeenCalledWith(store)
expect(registerTerminalPreviewHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService)
expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store, runtime)
@@ -36,6 +36,7 @@ import { registerNotificationHandlers } from '../notifications'
import { registerNotebookHandlers } from '../notebook'
import { registerOnboardingHandlers } from '../onboarding'
import { registerDashboardPopoutHandlers } from '../dashboard-popout'
import { registerWorkspaceSpaceWindowHandlers } from '../workspace-space-window'
import { registerTerminalPreviewHandlers } from '../terminal-preview'
import { registerDeveloperPermissionHandlers } from '../developer-permissions'
import { registerComputerUsePermissionHandlers } from '../computer-use-permissions'
@@ -200,6 +201,7 @@ export function registerCoreHandlers(
registerNotebookHandlers(store)
registerOnboardingHandlers(store)
registerDashboardPopoutHandlers(store, keybindings)
registerWorkspaceSpaceWindowHandlers(store)
registerTerminalPreviewHandlers(runtime)
registerDeveloperPermissionHandlers()
// Why: diagnostics handlers are wired alongside telemetry but the two
+83
View File
@@ -0,0 +1,83 @@
import { BrowserWindow, ipcMain } from 'electron'
import { getTrustedUIRendererWebContents, isTrustedUIRenderer } from './ui'
import {
closeWorkspaceSpaceWindow,
createOrFocusWorkspaceSpaceWindow,
getWorkspaceSpaceWindow,
isWorkspaceSpaceWindowRenderer
} from '../window/workspace-space-window'
import { safelyRevealWindow } from '../window/focus-existing-window'
import { isBackgroundLaunch } from '../window/foreground-activation-policy'
import { normalizeExecutionHostId } from '../../shared/execution-host'
import type { Store } from '../persistence'
export function registerWorkspaceSpaceWindowHandlers(store: Store): void {
for (const channel of [
'workspaceSpace:openWindow',
'workspaceSpace:closeWindow',
'workspaceSpace:publishWindowState',
'workspaceSpace:requestWindowState',
'workspaceSpace:revealWorktree',
'workspaceSpace:requestWorktreeRefresh',
'workspaceSpace:publishWindowAnalysis'
]) {
ipcMain.removeHandler(channel)
}
ipcMain.handle('workspaceSpace:openWindow', (event): void => {
if (isTrustedUIRenderer(event.sender)) {
createOrFocusWorkspaceSpaceWindow(store)
}
})
ipcMain.handle('workspaceSpace:closeWindow', (event): void => {
if (isWorkspaceSpaceWindowRenderer(event.sender)) {
closeWorkspaceSpaceWindow()
}
})
ipcMain.handle('workspaceSpace:publishWindowState', (event, state: unknown): void => {
if (isTrustedUIRenderer(event.sender)) {
getWorkspaceSpaceWindow()?.webContents.send('workspaceSpace:windowState', state)
}
})
ipcMain.handle('workspaceSpace:requestWindowState', (event): void => {
if (isWorkspaceSpaceWindowRenderer(event.sender)) {
getTrustedUIRendererWebContents()?.send('workspaceSpace:windowStateRequested')
}
})
ipcMain.handle(
'workspaceSpace:revealWorktree',
(event, worktreeId: unknown, hostId: unknown): void => {
if (!isWorkspaceSpaceWindowRenderer(event.sender) || typeof worktreeId !== 'string') {
return
}
if (hostId !== undefined && typeof hostId !== 'string') {
return
}
const executionHostId = normalizeExecutionHostId(hostId)
if (hostId !== undefined && executionHostId === null) {
return
}
const main = getTrustedUIRendererWebContents()
if (!main) {
return
}
if (!isBackgroundLaunch()) {
const window = BrowserWindow.fromWebContents(main)
if (window) {
safelyRevealWindow(window)
}
}
main.send('workspaceSpace:revealWorktree', worktreeId, executionHostId ?? undefined)
}
)
ipcMain.handle('workspaceSpace:requestWorktreeRefresh', (event): void => {
if (isWorkspaceSpaceWindowRenderer(event.sender)) {
getTrustedUIRendererWebContents()?.send('workspaceSpace:worktreeRefreshRequested')
}
})
ipcMain.handle('workspaceSpace:publishWindowAnalysis', (event, analysis: unknown): void => {
if (isWorkspaceSpaceWindowRenderer(event.sender) && analysis && typeof analysis === 'object') {
getTrustedUIRendererWebContents()?.send('workspaceSpace:windowAnalysis', analysis)
}
})
}
+4 -3
View File
@@ -6,6 +6,7 @@ import {
getNextDefaultOnAppearanceSettingValue
} from '../menu/register-app-menu'
import { zoomDashboardPopoutIfFocused } from '../window/dashboard-popout-window'
import { zoomWorkspaceSpaceWindowIfFocused } from '../window/workspace-space-window'
import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
import { mainProcessState as state } from './main-process-state'
import {
@@ -57,17 +58,17 @@ export async function initializeMainProcessI18nAndMenu(): Promise<void> {
// Why: menu zoom must act on the window the user is looking at — routing to
// the main window while the dashboard pop-out is focused zooms behind it.
onZoomIn: () => {
if (!zoomDashboardPopoutIfFocused('in')) {
if (!zoomDashboardPopoutIfFocused('in') && !zoomWorkspaceSpaceWindowIfFocused('in')) {
state.mainWindow?.webContents.send('terminal:zoom', 'in')
}
},
onZoomOut: () => {
if (!zoomDashboardPopoutIfFocused('out')) {
if (!zoomDashboardPopoutIfFocused('out') && !zoomWorkspaceSpaceWindowIfFocused('out')) {
state.mainWindow?.webContents.send('terminal:zoom', 'out')
}
},
onZoomReset: () => {
if (!zoomDashboardPopoutIfFocused('reset')) {
if (!zoomDashboardPopoutIfFocused('reset') && !zoomWorkspaceSpaceWindowIfFocused('reset')) {
state.mainWindow?.webContents.send('terminal:zoom', 'reset')
}
},
+2
View File
@@ -11,6 +11,7 @@ import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId
import { flushUnsavedCloudSessions } from '../orca-profiles/profile-cloud-session-owner'
import type { Store } from '../persistence'
import { closeDashboardPopout } from './dashboard-popout-window'
import { closeWorkspaceSpaceWindow } from './workspace-space-window'
import {
installMainWindowCloseLifecycle,
WINDOW_QUIT_RENDERER_ACK_TIMEOUT_MS
@@ -209,6 +210,7 @@ export function createMainWindow(
mainWindow.on('closed', () => {
closeDashboardPopout()
closeWorkspaceSpaceWindow()
state.clearInitialRevealFallbackTimer()
closeLifecycle.dispose()
focus.dispose()
+98
View File
@@ -0,0 +1,98 @@
import { BrowserWindow, nativeTheme, type WebContents } from 'electron'
import { join } from 'node:path'
import { is } from '@electron-toolkit/utils'
import { isBackgroundLaunch, showWindowWithoutStealingFocus } from './foreground-activation-policy'
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
import { sendToTrustedUIRenderer } from '../ipc/ui'
import type { Store } from '../persistence'
import { stepUIZoomLevel, type UIZoomDirection } from '../../shared/ui-zoom-level'
let spaceWindow: BrowserWindow | null = null
export function getWorkspaceSpaceWindow(): BrowserWindow | null {
return spaceWindow && !spaceWindow.isDestroyed() && !spaceWindow.webContents.isDestroyed()
? spaceWindow
: null
}
export function isWorkspaceSpaceWindowRenderer(sender: WebContents): boolean {
return getWorkspaceSpaceWindow()?.webContents === sender
}
export function zoomWorkspaceSpaceWindowIfFocused(direction: UIZoomDirection): boolean {
const window = getWorkspaceSpaceWindow()
if (!window || !window.isFocused()) {
return false
}
const contents = window.webContents
contents.setZoomLevel(stepUIZoomLevel(contents.getZoomLevel(), direction))
return true
}
export function createOrFocusWorkspaceSpaceWindow(store: Store): BrowserWindow {
const existing = getWorkspaceSpaceWindow()
if (existing) {
if (existing.isMinimized()) {
existing.restore()
}
if (!isBackgroundLaunch()) {
existing.focus()
}
return existing
}
const window = new BrowserWindow({
width: 1180,
height: 820,
minWidth: 700,
minHeight: 520,
title: 'Orca Space',
show: false,
autoHideMenuBar: true,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: true,
partition: 'orca-workspace-space-window',
webviewTag: false
}
})
spaceWindow = window
installPrivilegedWindowNavigationPolicy(window.webContents)
window.webContents.session.setPermissionRequestHandler((_contents, _permission, callback) =>
callback(false)
)
window.webContents.session.setPermissionCheckHandler(() => false)
window.webContents.on('dom-ready', () => {
if (!window.isDestroyed()) {
window.webContents.setZoomLevel(store.getUI().uiZoomLevel ?? 0)
}
})
let lastFollowedZoomLevel = store.getUI().uiZoomLevel ?? 0
const offUIChanged = store.onUIChanged((ui) => {
const level = ui.uiZoomLevel ?? 0
if (level !== lastFollowedZoomLevel && !window.isDestroyed()) {
lastFollowedZoomLevel = level
window.webContents.setZoomLevel(level)
}
})
window.once('ready-to-show', () => showWindowWithoutStealingFocus(window))
window.on('closed', () => {
offUIChanged()
if (spaceWindow === window) {
spaceWindow = null
sendToTrustedUIRenderer('workspaceSpace:windowClosed', null)
}
})
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
void window.loadURL(`${process.env.ELECTRON_RENDERER_URL}/space.html`)
} else {
void window.loadFile(join(__dirname, '../renderer/space.html'))
}
return window
}
export function closeWorkspaceSpaceWindow(): void {
getWorkspaceSpaceWindow()?.close()
}
@@ -37,6 +37,11 @@ vi.mock('./repo-worktrees', () => ({
listRepoWorktrees: listRepoWorktreesMock
}))
vi.mock('./workspace-space-scan-worker-client', async () => {
const { scanLocalWorkspaceSpaceWorktree } = await import('./workspace-space-local-scan')
return { scanLocalWorkspaceSpaceWorktreeViaWorker: scanLocalWorkspaceSpaceWorktree }
})
import { analyzeWorkspaceSpace } from './workspace-space-analysis'
function createStore(repo: Repo): Store {
@@ -1,18 +1,19 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeProcess from 'node:process'
import type { Repo } from '../shared/repo-types'
import type { Store } from './persistence'
const { execFileMock, listRepoWorktreesMock } = vi.hoisted(() => ({
execFileMock: vi.fn(),
const { spawnProcessMock, listRepoWorktreesMock } = vi.hoisted(() => ({
spawnProcessMock: vi.fn(),
listRepoWorktreesMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock
vi.mock('./worker-thread-child-process/worker-thread-child-process', () => ({
spawnProcessOnWorkerThread: spawnProcessMock
}))
vi.mock('node:process', async () => {
@@ -31,6 +32,11 @@ vi.mock('./repo-worktrees', () => ({
listRepoWorktrees: listRepoWorktreesMock
}))
vi.mock('./workspace-space-scan-worker-client', async () => {
const { scanLocalWorkspaceSpaceWorktree } = await import('./workspace-space-local-scan')
return { scanLocalWorkspaceSpaceWorktreeViaWorker: scanLocalWorkspaceSpaceWorktree }
})
import { analyzeWorkspaceSpace } from './workspace-space-analysis'
function createStore(repos: Repo[]): Store {
@@ -45,7 +51,7 @@ describe('analyzeWorkspaceSpace local du timeout', () => {
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'orca-space-du-timeout-'))
execFileMock.mockReset()
spawnProcessMock.mockReset()
listRepoWorktreesMock.mockReset()
})
@@ -79,7 +85,10 @@ describe('analyzeWorkspaceSpace local du timeout', () => {
}
])
const killMock = vi.fn()
execFileMock.mockImplementation(() => ({ kill: killMock }))
spawnProcessMock.mockImplementation(() => {
const child = new EventEmitter()
return Object.assign(child, { stdout: new EventEmitter(), kill: killMock })
})
vi.useFakeTimers()
let settled = false
@@ -89,12 +98,10 @@ describe('analyzeWorkspaceSpace local du timeout', () => {
})
await vi.waitFor(() =>
expect(execFileMock).toHaveBeenCalledWith(
'du',
['-k', '-d', '1', repoPath],
expect.any(Object),
expect.any(Function)
)
expect(spawnProcessMock).toHaveBeenCalledWith({
program: 'du',
args: ['-k', '-d', '1', repoPath]
})
)
await vi.advanceTimersByTimeAsync(120_000)
@@ -129,28 +136,24 @@ describe('analyzeWorkspaceSpace local du timeout', () => {
const completions: (() => void)[] = []
let active = 0
let peak = 0
execFileMock.mockImplementation(
(
_file: string,
args: string[],
_options: unknown,
callback: (error: Error | null, stdout: string) => void
) => {
const rootPath = args.at(-1)!
active += 1
peak = Math.max(peak, active)
completions.push(() => {
active -= 1
callback(null, `1\t${rootPath}\n`)
})
return { kill: vi.fn() }
}
)
spawnProcessMock.mockImplementation((spec: { args: string[] }) => {
const rootPath = spec.args.at(-1)!
const child = new EventEmitter()
const stdout = new EventEmitter()
active += 1
peak = Math.max(peak, active)
completions.push(() => {
active -= 1
stdout.emit('data', Buffer.from(`1\t${rootPath}\n`))
child.emit('close', 0)
})
return Object.assign(child, { stdout, kill: vi.fn() })
})
const scan = analyzeWorkspaceSpace(createStore(repos))
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(spawnProcessMock).toHaveBeenCalledTimes(1))
completions.shift()?.()
await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(2))
await vi.waitFor(() => expect(spawnProcessMock).toHaveBeenCalledTimes(2))
completions.shift()?.()
await expect(scan).resolves.toMatchObject({ scannedWorktreeCount: 2 })
+59 -4
View File
@@ -5,10 +5,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../shared/repo-types'
import type { Store } from './persistence'
const { listRepoWorktreesMock, getLocalProjectWorktreeGitOptionsMock } = vi.hoisted(() => ({
listRepoWorktreesMock: vi.fn(),
getLocalProjectWorktreeGitOptionsMock: vi.fn()
}))
const { listRepoWorktreesMock, getLocalProjectWorktreeGitOptionsMock, workerState } = vi.hoisted(
() => ({
listRepoWorktreesMock: vi.fn(),
getLocalProjectWorktreeGitOptionsMock: vi.fn(),
workerState: { fail: false }
})
)
vi.mock('./repo-worktrees', () => ({
createFolderWorktree: (repo: Repo) => ({
@@ -25,6 +28,26 @@ vi.mock('./project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: getLocalProjectWorktreeGitOptionsMock
}))
vi.mock('./worker-thread-child-process/worker-thread-child-process', () => ({
spawnProcessOnWorkerThread: () => {
throw new Error('Use portable traversal in this test')
}
}))
vi.mock('./workspace-space-scan-worker-client', async () => {
const { scanLocalWorkspaceSpaceWorktree } = await import('./workspace-space-local-scan')
return {
scanLocalWorkspaceSpaceWorktreeViaWorker: (
...args: Parameters<typeof scanLocalWorkspaceSpaceWorktree>
) => {
if (workerState.fail) {
throw new Error('Scan worker unavailable')
}
return scanLocalWorkspaceSpaceWorktree(...args)
}
}
})
import { analyzeWorkspaceSpace, WorkspaceSpaceScanCancelledError } from './workspace-space-analysis'
function createStore(repos: Repo[], worktreeHostId?: `runtime:${string}`): Store {
@@ -52,6 +75,7 @@ describe('analyzeWorkspaceSpace', () => {
tempDir = await mkdtemp(join(tmpdir(), 'orca-space-'))
listRepoWorktreesMock.mockReset()
getLocalProjectWorktreeGitOptionsMock.mockReset().mockReturnValue({})
workerState.fail = false
})
afterEach(async () => {
@@ -332,6 +356,37 @@ describe('analyzeWorkspaceSpace', () => {
expect(result.worktrees[0]?.sizeBytes).toBe(0)
})
it('reports a failed scan worker without traversing the workspace on the main thread', async () => {
const repoPath = join(tempDir!, 'repo')
await mkdir(repoPath)
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
}
])
workerState.fail = true
const result = await analyzeWorkspaceSpace(createStore([repo]))
expect(result.worktrees[0]).toMatchObject({
status: 'error',
sizeBytes: 0,
error: 'Scan worker unavailable'
})
})
it('reports disconnected SSH repos without failing the whole analysis', async () => {
const repo: Repo = {
id: 'repo-remote',
+30 -46
View File
@@ -1,12 +1,10 @@
import { execFile } from 'node:child_process'
import { platform } from 'node:process'
import type { Store } from './persistence'
import { spawnProcessOnWorkerThread } from './worker-thread-child-process/worker-thread-child-process'
import type {
WorkspaceSpaceAnalysis,
WorkspaceSpaceScanProgress
} from '../shared/workspace-space-types'
import { mapWithConcurrency } from '../shared/map-with-concurrency'
import { escapeRegex } from '../shared/string-utils'
import {
WorkspaceSpaceScanCancelledError,
createWorkspaceSpaceScanLimiter,
@@ -26,37 +24,14 @@ const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024
export { WorkspaceSpaceScanCancelledError }
function normalizeLocalDuPath(pathValue: string): string {
const separator = platform === 'win32' ? '\\' : '/'
const trimmed = pathValue.replace(new RegExp(`${escapeRegex(separator)}+$`), '')
return trimmed.length > 0 ? trimmed : pathValue
}
function parseWorkspaceSpaceDuOutput(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(normalizeLocalDuPath(match[2]), Number(match[1]) * 1024)
}
return sizes
}
async function readLocalDuDepthOne(
rootPath: string,
signal?: AbortSignal
): Promise<Map<string, number>> {
const stdout = await new Promise<string>((resolve, reject) => {
async function readLocalDuDepthOne(rootPath: string, signal?: AbortSignal): Promise<string> {
return new Promise<string>((resolve, reject) => {
let settled = false
let child: ReturnType<typeof execFile> | undefined
let child: ReturnType<typeof spawnProcessOnWorkerThread> | undefined
let onAbort: (() => void) | null = null
let timer: ReturnType<typeof setTimeout> | null = null
const chunks: Buffer[] = []
let outputBytes = 0
const settle = (callback: () => void): void => {
if (settled) {
return
@@ -79,7 +54,7 @@ async function readLocalDuDepthOne(
onAbort = () => {
settle(() => {
child?.kill()
reject(new Error('Workspace space scan cancelled'))
reject(new WorkspaceSpaceScanCancelledError())
})
}
signal?.addEventListener('abort', onAbort, { once: true })
@@ -88,23 +63,33 @@ async function readLocalDuDepthOne(
return
}
try {
child = execFile(
'du',
['-k', '-d', '1', rootPath],
{ encoding: 'utf8', maxBuffer: DU_MAX_BUFFER_BYTES, signal, timeout: DU_TIMEOUT_MS },
(error, output) => {
if (error) {
settle(() => reject(error))
return
}
settle(() => resolve(String(output)))
child = spawnProcessOnWorkerThread({ program: 'du', args: ['-k', '-d', '1', rootPath] })
child.stdout.on('data', (chunk: Buffer) => {
if (settled) {
return
}
)
outputBytes += chunk.length
if (outputBytes > DU_MAX_BUFFER_BYTES) {
settle(() => {
child?.kill()
reject(new Error('du output exceeded the scan buffer limit'))
})
return
}
chunks.push(chunk)
})
child.on('error', (error: Error) => settle(() => reject(error)))
child.on('close', (code: number | null) => {
settle(() =>
code === 0
? resolve(Buffer.concat(chunks).toString('utf8'))
: reject(new Error(`du exited with code ${code}`))
)
})
} catch (error) {
settle(() => reject(error))
}
})
return parseWorkspaceSpaceDuOutput(stdout)
}
export async function analyzeWorkspaceSpace(
@@ -138,8 +123,7 @@ export async function analyzeWorkspaceSpace(
limiters,
progress,
options,
readLocalDuDepthOne,
normalizeLocalDuPath
readLocalDuDepthOne
})
)
throwIfWorkspaceSpaceScanAborted(options.signal)
+31 -26
View File
@@ -5,6 +5,7 @@ import type { Repo } from '../shared/repo-types'
import type { Worktree } from '../shared/worktree/types'
import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types'
import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction'
import { escapeRegex } from '../shared/string-utils'
import { mapWithConcurrency } from '../shared/map-with-concurrency'
import {
scanWorkspaceSpaceEntryTree,
@@ -30,10 +31,23 @@ import {
const LOCAL_FS_CONCURRENCY = 48
export type ReadLocalDuDepthOne = (
rootPath: string,
signal?: AbortSignal
) => Promise<Map<string, number>>
function normalizeDuPath(pathValue: string): string {
const separator = platform === 'win32' ? '\\' : '/'
const trimmed = pathValue.replace(new RegExp(`${escapeRegex(separator)}+$`), '')
return trimmed.length > 0 ? trimmed : pathValue
}
function parseDuOutput(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
const match = /^(\d+)\s+(.+)$/.exec(normalizedLine)
if (match) {
sizes.set(normalizeDuPath(match[2]), Number(match[1]) * 1024)
}
}
return sizes
}
async function scanLocalEntry(
entryPath: string,
@@ -68,7 +82,6 @@ async function scanLocalTopLevelEntry(
entryPath: string,
name: string,
duSizes: Map<string, number>,
normalizeDuPath: (path: string) => string,
signal?: AbortSignal
): Promise<WorkspaceSpaceEntryScan> {
throwIfWorkspaceSpaceScanAborted(signal)
@@ -93,8 +106,7 @@ async function scanLocalWorktreeWithDu(
repo: Repo,
worktree: Worktree,
scannedAt: number,
readDu: ReadLocalDuDepthOne,
normalizeDuPath: (path: string) => string,
duSizes: Map<string, number>,
signal?: AbortSignal
): Promise<WorkspaceSpaceWorktree> {
throwIfWorkspaceSpaceScanAborted(signal)
@@ -112,19 +124,15 @@ async function scanLocalWorktreeWithDu(
...compact
})
}
const [entries, duSizes] = await Promise.all([
opendir(worktree.path).then(async (directory) => {
const admission = await collectWorkspaceSpaceDirectoryEntries(
directory,
worktree.path,
(entry) => entry.name,
createWorkspaceSpaceScanBudget(),
() => throwIfWorkspaceSpaceScanAborted(signal)
)
return admission.entries
}),
readDu(worktree.path, signal)
])
const directory = await opendir(worktree.path)
const admission = await collectWorkspaceSpaceDirectoryEntries(
directory,
worktree.path,
(entry) => entry.name,
createWorkspaceSpaceScanBudget(),
() => throwIfWorkspaceSpaceScanAborted(signal)
)
const entries = admission.entries
throwIfWorkspaceSpaceScanAborted(signal)
const childStats = await mapWithConcurrency(entries, LOCAL_FS_CONCURRENCY, async (entry) => {
try {
@@ -132,7 +140,6 @@ async function scanLocalWorktreeWithDu(
joinWorkspaceFilesystemPath(worktree.path, entry.name),
entry.name,
duSizes,
normalizeDuPath,
signal
)
} catch (error) {
@@ -189,19 +196,17 @@ export async function scanLocalWorkspaceSpaceWorktree(
repo: Repo,
worktree: Worktree,
scannedAt: number,
readDu: ReadLocalDuDepthOne,
normalizeDuPath: (path: string) => string,
duOutput: string | null,
signal?: AbortSignal
): Promise<WorkspaceSpaceWorktree> {
throwIfWorkspaceSpaceScanAborted(signal)
if (platform !== 'win32') {
if (platform !== 'win32' && duOutput !== null) {
try {
return await scanLocalWorktreeWithDu(
repo,
worktree,
scannedAt,
readDu,
normalizeDuPath,
parseDuOutput(duOutput),
signal
)
} catch (error) {
+32 -16
View File
@@ -1,4 +1,5 @@
import type { Store } from './persistence'
import { platform } from 'node:process'
import { isFolderRepo } from '../shared/repo-kind'
import type { Repo } from '../shared/repo-types'
import type { GitWorktreeInfo, Worktree } from '../shared/worktree/types'
@@ -26,10 +27,7 @@ import {
type AsyncLimiter
} from './workspace-space-scan-control'
import { createUnavailableWorkspaceSpaceRow } from './workspace-space-worktree-row'
import {
scanLocalWorkspaceSpaceWorktree,
type ReadLocalDuDepthOne
} from './workspace-space-local-scan'
import { scanLocalWorkspaceSpaceWorktreeViaWorker } from './workspace-space-scan-worker-client'
const WORKTREE_SCAN_CONCURRENCY = 3
@@ -155,8 +153,7 @@ export async function scanWorkspaceSpaceRepo(args: {
limiters: WorkspaceSpaceScanLimiters
progress: WorkspaceSpaceScanProgress
options: WorkspaceSpaceAnalyzeOptions
readLocalDuDepthOne: ReadLocalDuDepthOne
normalizeLocalDuPath: (path: string) => string
readLocalDuDepthOne: (rootPath: string, signal?: AbortSignal) => Promise<string>
}): Promise<WorkspaceSpaceRepoScanResult> {
const { repo, scannedAt, store, limiters, progress, options } = args
throwIfWorkspaceSpaceScanAborted(options.signal)
@@ -219,16 +216,35 @@ export async function scanWorkspaceSpaceRepo(args: {
? `SSH filesystem for "${filesystemRoute.connectionId}" is not connected.`
: `Host ${filesystemRoute.hostId} is not reachable from this process.`
)
: await limiters.localWorktree(() =>
scanLocalWorkspaceSpaceWorktree(
repo,
worktree,
scannedAt,
args.readLocalDuDepthOne,
args.normalizeLocalDuPath,
options.signal
)
)
: await limiters.localWorktree(async () => {
let duOutput: string | null = null
if (platform !== 'win32') {
try {
duOutput = await args.readLocalDuDepthOne(worktree.path, options.signal)
} catch {
throwIfWorkspaceSpaceScanAborted(options.signal)
}
}
try {
return await scanLocalWorkspaceSpaceWorktreeViaWorker(
repo,
worktree,
scannedAt,
duOutput,
options.signal
)
} catch (error) {
throwIfWorkspaceSpaceScanAborted(options.signal)
const classified = classifyWorkspaceSpaceError(error)
return createUnavailableWorkspaceSpaceRow(
repo,
worktree,
scannedAt,
classified.status,
classified.message
)
}
})
reportProgress(
progress,
{
@@ -0,0 +1,65 @@
import { existsSync } from 'node:fs'
import { Worker } from 'node:worker_threads'
import type { Repo } from '../shared/repo-types'
import type { Worktree } from '../shared/worktree/types'
import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types'
import { WorkerThreadRequestQueue } from './worker-thread-request-queue'
import { currentWorkerEntryLayout, resolveWorkerThreadEntryPath } from './worker-thread-entry-path'
import type {
WorkspaceSpaceScanWorkerRequest,
WorkspaceSpaceScanWorkerResponse
} from './workspace-space-scan-worker-protocol'
const WORKER_FILENAME = 'workspace-space-scan-worker-entry.js'
const WORKER_TIMEOUT_MS = 5 * 60_000
const IDLE_TEARDOWN_MS = 60_000
function createWorker(): Worker {
const path = resolveWorkerThreadEntryPath(currentWorkerEntryLayout(__dirname), WORKER_FILENAME)
if (!existsSync(path)) {
throw new Error(`Workspace space scan worker entry not found: ${path}`)
}
return new Worker(path)
}
let queue: WorkerThreadRequestQueue<
WorkspaceSpaceScanWorkerRequest,
WorkspaceSpaceScanWorkerResponse
> | null = null
function getQueue(): WorkerThreadRequestQueue<
WorkspaceSpaceScanWorkerRequest,
WorkspaceSpaceScanWorkerResponse
> {
queue ??= new WorkerThreadRequestQueue({
factory: createWorker,
idleTeardownMs: IDLE_TEARDOWN_MS,
maxConsecutiveDeaths: 3,
awaitRetirement: true,
createUnavailableError: (message) =>
new Error(`Workspace space worker unavailable: ${message}`),
describeTimeout: (timeoutMs) => `Workspace space worker timed out after ${timeoutMs}ms`,
describeExit: (code) => `Workspace space worker exited with code ${code}`,
describeCrashLoop: (error) => `Workspace space worker crashed repeatedly: ${error}`,
onUnavailable: (error) => console.warn('[workspace-space] Scan worker unavailable:', error)
})
return queue
}
export async function scanLocalWorkspaceSpaceWorktreeViaWorker(
repo: Repo,
worktree: Worktree,
scannedAt: number,
duOutput: string | null,
signal?: AbortSignal
): Promise<WorkspaceSpaceWorktree> {
const response = await getQueue().dispatch(
(id) => ({ id, repo, worktree, scannedAt, duOutput }),
WORKER_TIMEOUT_MS,
signal
)
if (!response.ok) {
throw new Error(response.error)
}
return response.row
}
@@ -0,0 +1,33 @@
import { parentPort } from 'node:worker_threads'
import { scanLocalWorkspaceSpaceWorktree } from './workspace-space-local-scan'
import type {
WorkspaceSpaceScanWorkerRequest,
WorkspaceSpaceScanWorkerResponse
} from './workspace-space-scan-worker-protocol'
if (!parentPort) {
throw new Error('Workspace space scan worker requires a parent port.')
}
const port = parentPort
port.on('message', (request: WorkspaceSpaceScanWorkerRequest) => {
void scanLocalWorkspaceSpaceWorktree(
request.repo,
request.worktree,
request.scannedAt,
request.duOutput
).then(
(row) => {
const response: WorkspaceSpaceScanWorkerResponse = { id: request.id, ok: true, row }
port.postMessage(response)
},
(error: unknown) => {
const response: WorkspaceSpaceScanWorkerResponse = {
id: request.id,
ok: false,
error: error instanceof Error ? error.message : String(error)
}
port.postMessage(response)
}
)
})
@@ -0,0 +1,15 @@
import type { Repo } from '../shared/repo-types'
import type { Worktree } from '../shared/worktree/types'
import type { WorkspaceSpaceWorktree } from '../shared/workspace-space-types'
export type WorkspaceSpaceScanWorkerRequest = {
id: number
repo: Repo
worktree: Worktree
scannedAt: number
duOutput: string | null
}
export type WorkspaceSpaceScanWorkerResponse =
| { id: number; ok: true; row: WorkspaceSpaceWorktree }
| { id: number; ok: false; error: string }
+16
View File
@@ -11,6 +11,7 @@ import type {
WorkspaceSpaceAnalyzeResult,
WorkspaceSpaceScanProgress
} from '../../shared/workspace-space-types'
import type { ExecutionHostId } from '../../shared/execution-host'
export type WorkspaceCleanupApi = {
scan: (
@@ -33,4 +34,19 @@ export type WorkspaceSpaceApi = {
getCachedAnalysis: () => Promise<WorkspaceSpaceAnalysis | null>
cancel: () => Promise<boolean>
onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void
openWindow?: () => Promise<void>
closeWindow?: () => Promise<void>
publishWindowState?: (state: unknown) => Promise<void>
requestWindowState?: () => Promise<void>
onWindowStateRequested?: (callback: () => void) => () => void
onWindowClosed?: (callback: () => void) => () => void
onWindowState?: (callback: (state: unknown) => void) => () => void
revealWorktree?: (worktreeId: string, executionHostId?: ExecutionHostId) => Promise<void>
onRevealWorktree?: (
callback: (worktreeId: string, executionHostId?: ExecutionHostId) => void
) => () => void
requestWorktreeRefresh?: () => Promise<void>
onWorktreeRefreshRequested?: (callback: () => void) => () => void
publishWindowAnalysis?: (analysis: WorkspaceSpaceAnalysis) => Promise<void>
onWindowAnalysis?: (callback: (analysis: WorkspaceSpaceAnalysis) => void) => () => void
}
+50 -1
View File
@@ -1,11 +1,60 @@
import { ipcRenderer } from 'electron'
import type { WorkspaceSpaceScanProgress } from '../../shared/workspace-space-types'
import type {
WorkspaceSpaceAnalysis,
WorkspaceSpaceScanProgress
} from '../../shared/workspace-space-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { PreloadApi } from '../api-types'
export const workspaceSpaceApi = {
analyze: () => ipcRenderer.invoke('workspaceSpace:analyze'),
getCachedAnalysis: () => ipcRenderer.invoke('workspaceSpace:getCachedAnalysis'),
cancel: () => ipcRenderer.invoke('workspaceSpace:cancel'),
openWindow: () => ipcRenderer.invoke('workspaceSpace:openWindow'),
closeWindow: () => ipcRenderer.invoke('workspaceSpace:closeWindow'),
publishWindowState: (state: unknown) =>
ipcRenderer.invoke('workspaceSpace:publishWindowState', state),
requestWindowState: () => ipcRenderer.invoke('workspaceSpace:requestWindowState'),
revealWorktree: (worktreeId: string, executionHostId?: ExecutionHostId) =>
ipcRenderer.invoke('workspaceSpace:revealWorktree', worktreeId, executionHostId),
requestWorktreeRefresh: () => ipcRenderer.invoke('workspaceSpace:requestWorktreeRefresh'),
publishWindowAnalysis: (analysis: WorkspaceSpaceAnalysis) =>
ipcRenderer.invoke('workspaceSpace:publishWindowAnalysis', analysis),
onWindowAnalysis: (callback: (analysis: WorkspaceSpaceAnalysis) => void) => {
const listener = (_event: Electron.IpcRendererEvent, analysis: WorkspaceSpaceAnalysis): void =>
callback(analysis)
ipcRenderer.on('workspaceSpace:windowAnalysis', listener)
return () => ipcRenderer.removeListener('workspaceSpace:windowAnalysis', listener)
},
onWindowStateRequested: (callback: () => void) => {
const listener = (): void => callback()
ipcRenderer.on('workspaceSpace:windowStateRequested', listener)
return () => ipcRenderer.removeListener('workspaceSpace:windowStateRequested', listener)
},
onWindowClosed: (callback: () => void) => {
const listener = (): void => callback()
ipcRenderer.on('workspaceSpace:windowClosed', listener)
return () => ipcRenderer.removeListener('workspaceSpace:windowClosed', listener)
},
onWindowState: (callback: (state: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: unknown): void => callback(state)
ipcRenderer.on('workspaceSpace:windowState', listener)
return () => ipcRenderer.removeListener('workspaceSpace:windowState', listener)
},
onRevealWorktree: (callback: (worktreeId: string, executionHostId?: ExecutionHostId) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
worktreeId: string,
executionHostId?: ExecutionHostId
): void => callback(worktreeId, executionHostId)
ipcRenderer.on('workspaceSpace:revealWorktree', listener)
return () => ipcRenderer.removeListener('workspaceSpace:revealWorktree', listener)
},
onWorktreeRefreshRequested: (callback: () => void) => {
const listener = (): void => callback()
ipcRenderer.on('workspaceSpace:worktreeRefreshRequested', listener)
return () => ipcRenderer.removeListener('workspaceSpace:worktreeRefreshRequested', listener)
},
onProgress: (callback) => {
const listener = (
_event: Electron.IpcRendererEvent,
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html class="native-shell">
<head>
<meta charset="UTF-8" />
<title>Orca Space</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/space.tsx"></script>
</body>
</html>
+2
View File
@@ -32,6 +32,7 @@ import { useOnboardingAndFeatureTips } from './app-shell/use-onboarding-and-feat
import { usePersistedUIWriter } from './app-shell/use-persisted-ui-writer'
import { useRuntimeGraphSync } from './app-shell/use-runtime-graph-sync'
import { useWindowVisibilityEffects } from './app-shell/use-window-visibility-effects'
import { SpaceWindowStatePublisher } from './components/workspace-space/SpaceWindowStatePublisher'
function App(): React.JSX.Element {
const layout = useAppChromeLayout()
@@ -93,6 +94,7 @@ function App(): React.JSX.Element {
<DocPreviewExternalLinkConfirmation />
<LinkRoutingPreferenceDialogProvider>
<AppBackgroundServices />
<SpaceWindowStatePublisher />
<AppWorkspaceShell layout={layout} floatingWorkspace={floatingWorkspace} />
<AppRootSurfaces
floatingWorkspace={floatingWorkspace}
@@ -63,16 +63,16 @@ export function WorkspaceSpaceCompactPanel({
)
: analysis.unavailableWorktreeCount > 0
? translate(
'auto.components.status.bar.WorkspaceSpaceCompactPanel.bef4dc0457',
'{{value0}} reclaimable · {{value1}} unavailable',
'workspaceSpace.compact.linkedAndUnavailable',
'{{value0}} in linked worktrees · {{value1}} unavailable',
{
value0: formatBytes(analysis.reclaimableBytes),
value1: analysis.unavailableWorktreeCount
}
)
: translate(
'auto.components.status.bar.WorkspaceSpaceCompactPanel.bef4dc0457',
'{{value0}} reclaimable · {{value1}} workspaces',
'workspaceSpace.compact.linkedAndScanned',
'{{value0}} in linked worktrees · {{value1}} workspaces',
{
value0: formatBytes(analysis.reclaimableBytes),
value1: analysis.scannedWorktreeCount
@@ -153,10 +153,7 @@ export function WorkspaceSpaceCompactPanel({
</div>
<div className="rounded border border-border/60 bg-background/40 px-2 py-1">
<div className="text-muted-foreground">
{translate(
'auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0',
'Freeable'
)}
{translate('workspaceSpace.compact.linked', 'Linked')}
</div>
<div className="truncate font-medium text-foreground">
{formatBytes(analysis.reclaimableBytes)}
@@ -9,6 +9,7 @@ import { getWorkspaceSpaceDeleteState } from './workspace-space-state-resolution
import { getWorkspaceSpaceWorktreeIdentity } from './workspace-space-delete-selection'
import type { WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types'
import type { useWorkspaceSpaceManagerBindings } from './use-workspace-space-manager-bindings'
import { getWorktreeOnHostFromState } from '@/store/selectors'
type WorkspaceSpaceManagerBindings = ReturnType<typeof useWorkspaceSpaceManagerBindings>
@@ -37,7 +38,8 @@ export function useWorkspaceSpaceDecisionProjection(bindings: WorkspaceSpaceMana
runtimePaneTitlesByTabId,
settings,
tabsByWorktree,
worktreeMap
worktreeMap,
worktreesByRepo
} = bindings
const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees])
@@ -76,6 +78,11 @@ export function useWorkspaceSpaceDecisionProjection(bindings: WorkspaceSpaceMana
getWorkspaceDecisionDetails(worktree, {
repoMap,
worktreeMap,
worktreeRecord: getWorktreeOnHostFromState(
{ worktreesByRepo },
worktree.worktreeId,
worktree.executionHostId
),
repos,
tabsByWorktree,
ptyIdsByTabId,
@@ -125,7 +132,8 @@ export function useWorkspaceSpaceDecisionProjection(bindings: WorkspaceSpaceMana
settings,
sourceRows,
tabsByWorktree,
worktreeMap
worktreeMap,
worktreesByRepo
])
const getDeleteStateForWorktree = useCallback(
(worktree: WorkspaceSpaceWorktree) =>
@@ -21,6 +21,7 @@ export function useWorkspaceSpaceManagerBindings() {
const repoMap = useAppStore((state) => getRepoMapFromState(state))
const repos = useAppStore((state) => state.repos)
const worktreeMap = useAppStore((state) => getWorktreeMapFromState(state))
const worktreesByRepo = useAppStore((state) => state.worktreesByRepo)
const tabsByWorktree = useAppStore((state) => state.tabsByWorktree)
const ptyIdsByTabId = useAppStore((state) => state.ptyIdsByTabId)
const agentStatusByPaneKey = useAppStore((state) => state.agentStatusByPaneKey)
@@ -75,6 +76,7 @@ export function useWorkspaceSpaceManagerBindings() {
repoMap,
repos,
worktreeMap,
worktreesByRepo,
tabsByWorktree,
ptyIdsByTabId,
agentStatusByPaneKey,
@@ -32,9 +32,13 @@ export function useWorkspaceSpaceManagerPanel() {
sortKey
} = bindings
const refresh = useCallback((): void => {
void refreshWorkspaceSpace().catch(() => {
/* scanError is stored by the slice */
})
void refreshWorkspaceSpace()
.then((analysis) => {
void window.api.workspaceSpace.publishWindowAnalysis?.(analysis)
})
.catch(() => {
/* scanError is stored by the slice */
})
}, [refreshWorkspaceSpace])
const cancelScan = useCallback((): void => {
@@ -99,6 +103,7 @@ export function useWorkspaceSpaceManagerPanel() {
return
}
removeWorkspaceSpaceWorktrees(deletedTargets)
void window.api.workspaceSpace.requestWorktreeRefresh?.()
const deletedIdentities = new Set(
deletedTargets.map((target) =>
composeWorktreeHostIdentity(target.executionHostId ?? undefined, target.id)
@@ -70,11 +70,16 @@ export function useWorkspaceSpaceManagerProjection(args: {
const rows = useMemo(
() =>
sortWorkspaceSpaceRows(
filterWorkspaceSpaceRows(sourceRows, query, onlyDeletable),
filterWorkspaceSpaceRows(sourceRows, query, onlyDeletable).filter(
(row) =>
!onlyDeletable ||
decisionDetailsByWorktreeId.get(getWorkspaceSpaceWorktreeIdentity(row))
?.canOpenWorkspace === true
),
sortKey,
sortDirection
),
[onlyDeletable, query, sortDirection, sortKey, sourceRows]
[decisionDetailsByWorktreeId, onlyDeletable, query, sortDirection, sortKey, sourceRows]
)
const nextInspectedWorktreeId = resolveWorkspaceSpaceInspectedWorktreeId(
@@ -97,7 +97,7 @@ describe('WorkspaceSpaceBreakdownList', () => {
expect(container.textContent).toContain('1 top-level items')
})
it('shows the omitted aggregate rather than an empty state when every item was omitted', () => {
it('explains how to inspect folders when a saved scan omits every item', () => {
const { container } = render(
<WorkspaceSpaceBreakdownList
isScanning={false}
@@ -106,6 +106,7 @@ describe('WorkspaceSpaceBreakdownList', () => {
)
expect(container.textContent).not.toContain('No files found.')
expect(renderedRowNames(container)).toEqual(['Other top-level items (4)'])
expect(renderedRowNames(container)).toEqual([])
expect(container.textContent).toContain('Refresh to inspect this workspace.')
})
})
@@ -109,6 +109,13 @@ export function WorkspaceSpaceBreakdownList({
'No files found.'
)}
</div>
) : worktree.topLevelItems.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
{translate(
'workspaceSpace.savedScan.folderDetailsUnavailable',
'Folder details are not saved with past scans. Refresh to inspect this workspace.'
)}
</div>
) : (
<div className="max-h-72 overflow-y-auto scrollbar-sleek px-3 py-3">
<div className="space-y-2">
@@ -37,6 +37,7 @@ export type WorkspaceDecisionDetails = {
export type WorkspaceDecisionInputs = {
repoMap: ReadonlyMap<string, Repo>
worktreeMap: ReadonlyMap<string, Worktree>
worktreeRecord?: Worktree
repos?: readonly Repo[]
tabsByWorktree: Record<string, readonly TerminalTab[]>
ptyIdsByTabId: Record<string, string[]>
@@ -111,7 +112,13 @@ export function getWorkspaceDecisionDetails(
worktree: WorkspaceSpaceWorktree,
inputs: WorkspaceDecisionInputs
): WorkspaceDecisionDetails {
const workspaceRecord = inputs.worktreeMap.get(worktree.worktreeId)
const candidateWorkspaceRecord =
inputs.worktreeRecord ?? inputs.worktreeMap.get(worktree.worktreeId)
const workspaceRecord =
candidateWorkspaceRecord &&
(!worktree.executionHostId || candidateWorkspaceRecord.hostId === worktree.executionHostId)
? candidateWorkspaceRecord
: undefined
const tabs = inputs.tabsByWorktree[worktree.worktreeId] ?? []
const openFiles = inputs.openFiles.filter((file) => file.worktreeId === worktree.worktreeId)
const dirtyEditorBufferCount = openFiles.filter(
@@ -5,6 +5,7 @@ import { translate } from '@/i18n/i18n'
import { Metric, UpdatedMetric } from './workspace-space-metrics'
import { WorkspaceSpaceTreemap } from './workspace-space-treemap'
import { WorkspaceSpaceBreakdownList } from './workspace-space-breakdown-list'
import { getWorkspaceSpaceWorktreeIdentity } from './workspace-space-delete-selection'
import type { useWorkspaceSpaceManagerPanel } from './use-workspace-space-manager-panel'
type WorkspaceSpaceManagerModel = ReturnType<typeof useWorkspaceSpaceManagerPanel>
@@ -31,6 +32,29 @@ export function WorkspaceSpaceManagerOverview({
sourceRows,
zoomedWorktree
} = model
if (!analysis && !isScanning) {
return (
<div className="flex min-h-80 flex-col items-center justify-center rounded-lg border border-border bg-card px-6 py-10 text-center">
<div className="mb-5 flex size-12 items-center justify-center rounded-lg border border-border bg-muted/40">
<HardDrive className="size-6 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold text-foreground">
{translate('workspaceSpace.empty.title', 'See where workspace storage goes')}
</h2>
<p className="mt-2 max-w-md text-sm text-muted-foreground">
{translate(
'workspaceSpace.empty.description',
'Scan your workspaces to compare sizes, inspect their largest folders, and review space you can reclaim.'
)}
</p>
{scanError ? <p className="mt-4 max-w-md text-sm text-destructive">{scanError}</p> : null}
<Button variant="default" size="sm" onClick={refresh} className="mt-6">
<RefreshCw className="size-4" />
{translate('workspaceSpace.empty.scan', 'Scan workspaces')}
</Button>
</div>
)
}
return (
<>
<div className="grid overflow-hidden rounded-lg border border-border/65 bg-background/35 md:grid-cols-4 md:divide-x md:divide-border/60">
@@ -42,10 +66,7 @@ export function WorkspaceSpaceManagerOverview({
value={analysis ? formatBytes(analysis.totalSizeBytes) : '—'}
/>
<Metric
label={translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.83f1a0a932',
'Reclaimable'
)}
label={translate('workspaceSpace.metric.linkedWorktrees', 'Linked worktrees')}
value={analysis ? formatBytes(analysis.reclaimableBytes) : '—'}
/>
<Metric
@@ -80,8 +101,8 @@ export function WorkspaceSpaceManagerOverview({
{ value0: progressLabel ?? 'Scanning workspace sizes' }
)
: translate(
'auto.components.status.bar.WorkspaceSpaceManagerPanel.d595295d7d',
'{{value0}} can be reclaimed from linked worktrees.',
'workspaceSpace.summary.linkedWorktrees',
'{{value0}} is used by linked worktrees. Managed worktrees can be removed here.',
{ value0: formatBytes(analysis.reclaimableBytes) }
)
: isScanning
@@ -163,11 +184,13 @@ export function WorkspaceSpaceManagerOverview({
) : null}
{hasRows || isInitialScan ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(20rem,0.6fr)]">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(20rem,0.6fr)]">
<WorkspaceSpaceTreemap
rows={sourceRows}
isScanning={isInitialScan}
selectedWorktreeId={inspectedWorktree?.worktreeId ?? null}
selectedWorktreeId={
inspectedWorktree ? getWorkspaceSpaceWorktreeIdentity(inspectedWorktree) : null
}
zoomedWorktree={zoomedWorktree}
onSelect={setInspectedWorktreeId}
onZoomChange={setTreemapZoomWorktreeId}
@@ -141,9 +141,15 @@ export function WorkspaceSpaceManagerTable({
onToggleSelected={() => toggleSelection(worktree)}
onInspect={() => setInspectedWorktreeId(identity)}
onOpenWorkspace={() =>
activateAndRevealWorktree(worktree.worktreeId, {
navigationIntent: 'user-open'
})
window.location.pathname.endsWith('/space.html')
? void window.api.workspaceSpace.revealWorktree?.(
worktree.worktreeId,
worktree.executionHostId
)
: activateAndRevealWorktree(worktree.worktreeId, {
navigationIntent: 'user-open',
executionHostId: worktree.executionHostId
})
}
onDelete={() => deleteWorktrees([worktree])}
onForceDelete={() => forceDeleteWorktree(worktree)}
@@ -154,7 +160,7 @@ export function WorkspaceSpaceManagerTable({
</div>
</div>
</div>
) : (
) : !analysis && !scanError ? null : (
<div className="rounded-lg border border-border/70 bg-background/30 px-4 py-10 text-center text-sm text-muted-foreground">
{scanError
? translate(
@@ -32,7 +32,7 @@ export function WorkspaceSpaceManagerToolbar({
} = model
return (
<>
{hasRows ? (
{selectedDeletableIds.length > 0 ? (
<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">
@@ -115,6 +115,7 @@ function worktreeRecord(overrides: Partial<Worktree> = {}): Worktree {
head: 'abc123',
isBare: false,
isMainWorktree: false,
hostId: 'local',
comment: '',
linkedIssue: null,
linkedPR: null,
@@ -171,7 +172,7 @@ describe('workspace space presentation helpers', () => {
)
const ssh = getWorkspaceDecisionDetails(
row({ worktreeId: 'wt', executionHostId: 'ssh:builder' }),
inputs
{ ...inputs, worktreeRecord: worktreeRecord({ hostId: 'ssh:builder' }) }
)
expect(local.isActive).toBe(true)
@@ -336,6 +337,9 @@ describe('workspace space presentation helpers', () => {
const workspace = row({ worktreeId: 'with-browser', canDelete: true, status: 'ok' })
expect(isWorkspaceSpaceRowReadyToDelete(workspace, ready())).toBe(true)
expect(isWorkspaceSpaceRowReadyToDelete(workspace, ready({ canOpenWorkspace: false }))).toBe(
false
)
expect(isWorkspaceSpaceRowReadyToDelete(workspace, ready({ browserTabCount: 1 }))).toBe(false)
})
@@ -27,6 +27,7 @@ export function isWorkspaceSpaceFilterQueryTooLarge(
export type WorkspaceSpaceDeleteReadiness = {
isActive: boolean
canOpenWorkspace?: boolean
changedFileCount: number | null
dirtyEditorBufferCount: number
activeAgentCount: number
@@ -243,6 +244,7 @@ export function isWorkspaceSpaceRowReadyToDelete(
worktree.status === 'ok' &&
!worktree.isMainWorktree &&
readiness !== undefined &&
readiness.canOpenWorkspace !== false &&
!readiness.isActive &&
readiness.changedFileCount === 0 &&
readiness.dirtyEditorBufferCount === 0 &&
@@ -115,6 +115,13 @@ export function WorkspaceSpaceStatusBadge({
</Badge>
)
}
if (decisionDetails?.canOpenWorkspace === false) {
return (
<Badge variant="outline">
{translate('workspaceSpace.status.notManaged', 'Not managed')}
</Badge>
)
}
return (
<Badge
variant="outline"
@@ -5,19 +5,20 @@ import { cn } from '@/lib/utils'
import { Button } from '../ui/button'
import { formatBytes } from './workspace-space-format'
import { buildTreemapLayout, type TreemapRect } from './workspace-space-layout'
import { getWorkspaceSpaceWorktreeIdentity } from './workspace-space-delete-selection'
import { translate } from '@/i18n/i18n'
const TREEMAP_FILLS = [
'color-mix(in srgb, var(--chart-2) 34%, var(--card))',
'color-mix(in srgb, var(--foreground) 20%, var(--card))',
'color-mix(in srgb, var(--chart-4) 28%, var(--card))',
'color-mix(in srgb, var(--primary) 24%, var(--card))',
'color-mix(in srgb, var(--chart-1) 38%, var(--card))'
'color-mix(in srgb, var(--chart-2) 58%, var(--card))',
'color-mix(in srgb, var(--chart-4) 56%, var(--card))',
'color-mix(in srgb, var(--chart-1) 50%, var(--card))',
'color-mix(in srgb, var(--chart-3) 52%, var(--card))',
'color-mix(in srgb, var(--chart-5) 54%, var(--card))'
]
function getTreemapFill(rect: TreemapRect, selected: boolean): string {
if (selected) {
return 'color-mix(in srgb, var(--ring) 40%, var(--card))'
return 'color-mix(in srgb, var(--chart-2) 65%, var(--card))'
}
return TREEMAP_FILLS[rect.index % TREEMAP_FILLS.length]
}
@@ -37,7 +38,8 @@ export function WorkspaceSpaceTreemap({
onSelect: (worktreeId: string) => void
onZoomChange: (worktreeId: string | null) => void
}): React.JSX.Element {
const selectedWorktree = rows.find((row) => row.worktreeId === selectedWorktreeId) ?? null
const selectedWorktree =
rows.find((row) => getWorkspaceSpaceWorktreeIdentity(row) === selectedWorktreeId) ?? null
const canZoomSelected =
!!selectedWorktree &&
selectedWorktree.status === 'ok' &&
@@ -57,7 +59,7 @@ export function WorkspaceSpaceTreemap({
: rows
.filter((row) => row.status === 'ok' && row.sizeBytes > 0)
.map((row) => ({
id: row.worktreeId,
id: getWorkspaceSpaceWorktreeIdentity(row),
label: row.displayName,
sizeBytes: row.sizeBytes
}))
@@ -101,7 +103,12 @@ export function WorkspaceSpaceTreemap({
}
return (
<div className="relative h-72 overflow-hidden rounded-lg border border-border/70 bg-muted/20">
<div className="relative h-72 overflow-hidden rounded-lg border border-border bg-card">
<div className="pointer-events-none absolute left-3 top-3 z-10 rounded-md border border-border bg-background/90 px-2.5 py-1 text-xs font-medium text-foreground shadow-xs backdrop-blur">
{zoomedWorktree
? translate('workspaceSpace.chart.folderSizes', 'Folder sizes')
: translate('workspaceSpace.chart.workspaceSizes', 'Workspace sizes')}
</div>
<div className="absolute right-2 top-2 z-10 flex max-w-[calc(100%-1rem)] items-center gap-2">
{zoomedWorktree ? (
<>
@@ -122,7 +129,7 @@ export function WorkspaceSpaceTreemap({
<Button
variant="outline"
size="xs"
onClick={() => onZoomChange(selectedWorktree.worktreeId)}
onClick={() => onZoomChange(getWorkspaceSpaceWorktreeIdentity(selectedWorktree))}
className="gap-1.5 bg-background/90 px-2.5 backdrop-blur"
>
<ZoomIn className="size-3" />
@@ -157,7 +164,7 @@ export function WorkspaceSpaceTreemap({
<div
key={rect.id}
title={`${rect.label} • ${formatBytes(rect.sizeBytes)}`}
className="absolute overflow-hidden border border-background/80 p-2 text-left"
className="absolute overflow-hidden border border-background/90 p-2 text-left"
style={rectStyle}
>
{rectContent}
@@ -173,7 +180,7 @@ export function WorkspaceSpaceTreemap({
title={`${rect.label} • ${formatBytes(rect.sizeBytes)}`}
onClick={() => onSelect(rect.id)}
className={cn(
'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',
'absolute overflow-hidden border border-background/90 p-2 text-left transition-[filter,outline] hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
selected && 'ring-2 ring-ring ring-offset-1 ring-offset-background'
)}
style={rectStyle}
@@ -0,0 +1,93 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getState: vi.fn(() => ({ revision: 1 })),
subscribe: vi.fn((_listener: (next: unknown, previous: unknown) => void) => vi.fn()),
publishWindowState: vi.fn(async () => undefined),
onWindowStateRequested: vi.fn((_callback: () => void) => vi.fn()),
onWindowClosed: vi.fn((_callback: () => void) => vi.fn()),
onRevealWorktree: vi.fn((_callback: (worktreeId: string, executionHostId?: string) => void) =>
vi.fn()
),
activateAndRevealWorktree: vi.fn(),
onWorktreeRefreshRequested: vi.fn(() => vi.fn()),
onWindowAnalysis: vi.fn(() => vi.fn())
}))
vi.mock('@/store', () => ({
useAppStore: { getState: mocks.getState, subscribe: mocks.subscribe }
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: mocks.activateAndRevealWorktree
}))
vi.mock('./space-window-state', () => ({
pickSpaceWindowState: (state: unknown) => state,
spaceWindowStateChanged: (previous: unknown, next: unknown) => previous !== next
}))
import { SpaceWindowStatePublisher } from './SpaceWindowStatePublisher'
describe('SpaceWindowStatePublisher', () => {
let root: Root
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
workspaceSpace: {
publishWindowState: mocks.publishWindowState,
onWindowStateRequested: mocks.onWindowStateRequested,
onWindowClosed: mocks.onWindowClosed,
onRevealWorktree: mocks.onRevealWorktree,
onWorktreeRefreshRequested: mocks.onWorktreeRefreshRequested,
onWindowAnalysis: mocks.onWindowAnalysis
}
}
})
root = createRoot(document.createElement('div'))
})
afterEach(async () => {
await act(async () => root.unmount())
vi.useRealTimers()
})
it('publishes only while Space is open and stops watching on close', async () => {
await act(async () => root.render(<SpaceWindowStatePublisher />))
expect(mocks.subscribe).not.toHaveBeenCalled()
expect(mocks.publishWindowState).not.toHaveBeenCalled()
await act(async () => mocks.onWindowStateRequested.mock.calls[0][0]())
expect(mocks.subscribe).toHaveBeenCalledOnce()
expect(mocks.publishWindowState).toHaveBeenCalledOnce()
const onStoreChange = mocks.subscribe.mock.calls[0][0]
const offStore = mocks.subscribe.mock.results[0].value
await act(async () => onStoreChange({ revision: 2 }, { revision: 1 }))
await act(async () => vi.advanceTimersByTime(100))
expect(mocks.publishWindowState).toHaveBeenCalledTimes(2)
await act(async () => mocks.onWindowClosed.mock.calls[0][0]())
expect(offStore).toHaveBeenCalledOnce()
await act(async () => onStoreChange({ revision: 3 }, { revision: 2 }))
await act(async () => vi.advanceTimersByTime(100))
expect(mocks.publishWindowState).toHaveBeenCalledTimes(2)
})
it('reveals a workspace on its selected execution host', async () => {
await act(async () => root.render(<SpaceWindowStatePublisher />))
await act(async () => mocks.onRevealWorktree.mock.calls[0][0]('worktree-1', 'ssh:server-1'))
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', {
navigationIntent: 'user-open',
executionHostId: 'ssh:server-1'
})
})
})
@@ -0,0 +1,68 @@
import { useEffect } from 'react'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { pickSpaceWindowState, spaceWindowStateChanged } from './space-window-state'
export function SpaceWindowStatePublisher(): null {
useEffect(() => {
const api = window.api.workspaceSpace
if (!api.publishWindowState || !api.onWindowStateRequested) {
return
}
let windowOpen = false
let timer: ReturnType<typeof setTimeout> | null = null
let offStore: (() => void) | null = null
const publish = (): void => {
timer = null
if (windowOpen) {
void api.publishWindowState?.(pickSpaceWindowState(useAppStore.getState()))
}
}
const queuePublish = (): void => {
if (windowOpen && timer === null) {
timer = setTimeout(publish, 100)
}
}
const offRequest = api.onWindowStateRequested(() => {
windowOpen = true
offStore ??= useAppStore.subscribe((next, previous) => {
if (spaceWindowStateChanged(previous, next)) {
queuePublish()
}
})
publish()
})
const offClose = api.onWindowClosed?.(() => {
windowOpen = false
offStore?.()
offStore = null
if (timer !== null) {
clearTimeout(timer)
timer = null
}
})
const offReveal = api.onRevealWorktree?.((worktreeId, executionHostId) => {
activateAndRevealWorktree(worktreeId, { navigationIntent: 'user-open', executionHostId })
})
const offRefresh = api.onWorktreeRefreshRequested?.(() => {
void useAppStore.getState().fetchAllWorktrees()
})
const offAnalysis = api.onWindowAnalysis?.((analysis) => {
if (typeof analysis.scannedAt === 'number') {
useAppStore.setState({ workspaceSpaceAnalysis: analysis })
}
})
return () => {
if (timer !== null) {
clearTimeout(timer)
}
offStore?.()
offRequest()
offClose?.()
offReveal?.()
offRefresh?.()
offAnalysis?.()
}
}, [])
return null
}
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { ArrowLeft, HardDrive } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { ArrowLeft, HardDrive, X } from 'lucide-react'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { WorkspaceSpaceManagerPanel } from '../status-bar/WorkspaceSpaceManagerPanel'
@@ -9,6 +9,14 @@ import { translate } from '@/i18n/i18n'
export default function WorkspaceSpacePage(): React.JSX.Element {
const closeSpacePage = useAppStore((state) => state.closeSpacePage)
const isSeparateWindow = window.location.pathname.endsWith('/space.html')
const close = useCallback((): void => {
if (isSeparateWindow) {
void window.api.workspaceSpace.closeWindow?.()
} else {
closeSpacePage()
}
}, [closeSpacePage, isSeparateWindow])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
@@ -20,28 +28,30 @@ export default function WorkspaceSpacePage(): React.JSX.Element {
if (hasVisibleOverlay()) {
return
}
const target = event.target as HTMLElement | null
const target = event.target instanceof HTMLElement ? event.target : null
if (
target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
) {
return
}
event.preventDefault()
closeSpacePage()
close()
}
// 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])
}, [close])
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<div className="flex shrink-0 items-center gap-3 border-b border-border px-5 py-3">
<Button variant="outline" size="sm" onClick={closeSpacePage} className="shrink-0 gap-1.5">
<ArrowLeft className="size-3.5" />
{translate('auto.components.workspace.space.WorkspaceSpacePage.ecf72fdc3b', 'Back')}
<Button variant="ghost" size="sm" onClick={close} className="shrink-0">
{isSeparateWindow ? <X className="size-3.5" /> : <ArrowLeft className="size-3.5" />}
{isSeparateWindow
? translate('workspaceSpace.window.close', 'Close')
: translate('auto.components.workspace.space.WorkspaceSpacePage.ecf72fdc3b', 'Back')}
</Button>
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted/30">
@@ -62,7 +72,7 @@ export default function WorkspaceSpacePage(): React.JSX.Element {
<p className="truncate text-xs text-muted-foreground">
{translate(
'auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb',
'Workspace disk usage and reclaimable worktree storage.'
'See what uses space across your workspaces and what you can safely reclaim.'
)}
</p>
</div>
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import type {
WorkspaceSpaceAnalysis,
WorkspaceSpaceWorktree
} from '../../../../shared/workspace-space-types'
import { selectSpaceWindowAnalysis } from './space-window-state'
function worktree(topLevelItems: WorkspaceSpaceWorktree['topLevelItems']): WorkspaceSpaceWorktree {
return {
worktreeId: 'wt',
repoId: 'repo',
repoDisplayName: 'repo',
repoPath: '/repo',
displayName: 'workspace',
path: '/workspace',
branch: 'main',
isMainWorktree: false,
isRemote: false,
isSparse: false,
canDelete: true,
lastActivityAt: 0,
status: 'ok',
error: null,
scannedAt: 1,
sizeBytes: 1,
reclaimableBytes: 1,
skippedEntryCount: 0,
topLevelItems,
omittedTopLevelItemCount: 0,
omittedTopLevelSizeBytes: 0
}
}
function analysis(rows: WorkspaceSpaceWorktree[], scannedAt = 1): WorkspaceSpaceAnalysis {
return {
scannedAt,
totalSizeBytes: rows.length,
reclaimableBytes: rows.length,
worktreeCount: rows.length,
scannedWorktreeCount: rows.length,
unavailableWorktreeCount: 0,
repos: [],
worktrees: rows
}
}
describe('selectSpaceWindowAnalysis', () => {
it('restores live folder details over the persisted summary for the same scan', () => {
const cached = analysis([worktree([])])
const live = analysis([
worktree([{ name: 'src', path: '/workspace/src', kind: 'directory', sizeBytes: 1 }])
])
expect(selectSpaceWindowAnalysis(cached, live)).toBe(live)
expect(selectSpaceWindowAnalysis(live, cached)).toBe(live)
})
it('keeps a same-scan deletion and prefers a newer scan', () => {
const beforeDelete = analysis([worktree([])])
const afterDelete = analysis([])
const refreshed = analysis([worktree([])], 2)
expect(selectSpaceWindowAnalysis(afterDelete, beforeDelete)).toBe(afterDelete)
expect(selectSpaceWindowAnalysis(beforeDelete, afterDelete)).toBe(afterDelete)
expect(selectSpaceWindowAnalysis(afterDelete, refreshed)).toBe(refreshed)
})
})
@@ -0,0 +1,81 @@
import type { AppState } from '@/store/types'
export function pickSpaceWindowState(state: AppState) {
return {
workspaceSpaceAnalysis: state.workspaceSpaceAnalysis,
workspaceSpaceScanError: state.workspaceSpaceScanError,
workspaceSpaceScanProgress: state.workspaceSpaceScanProgress,
workspaceSpaceScanning: state.workspaceSpaceScanning,
workspaceSpaceMeasurements: state.workspaceSpaceMeasurements,
repos: state.repos,
worktreesByRepo: state.worktreesByRepo,
detectedWorktreesByRepo: state.detectedWorktreesByRepo,
runtimeEnvironments: state.runtimeEnvironments,
runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated,
removedRuntimeEnvironmentIds: state.removedRuntimeEnvironmentIds,
folderWorkspaces: state.folderWorkspaces,
projectGroups: state.projectGroups,
restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey,
settings: state.settings,
tabsByWorktree: state.tabsByWorktree,
ptyIdsByTabId: state.ptyIdsByTabId,
agentStatusByPaneKey: state.agentStatusByPaneKey,
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId,
agentStatusEpoch: state.agentStatusEpoch,
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
openFiles: state.openFiles,
editorDrafts: state.editorDrafts,
browserTabsByWorktree: state.browserTabsByWorktree,
gitStatusByWorktree: state.gitStatusByWorktree,
remoteStatusesByWorktree: state.remoteStatusesByWorktree,
hostedReviewCache: state.hostedReviewCache,
issueCache: state.issueCache,
linearIssueCache: state.linearIssueCache,
activeWorktreeId: state.activeWorktreeId,
activeWorkspaceExecutionHostId: state.activeWorkspaceExecutionHostId,
deleteStateByWorktreeId: state.deleteStateByWorktreeId,
worktreeLineageById: state.worktreeLineageById,
sshConnectionStates: state.sshConnectionStates,
sshTargetLabels: state.sshTargetLabels
}
}
export type SpaceWindowState = ReturnType<typeof pickSpaceWindowState>
export function selectSpaceWindowAnalysis(
current: SpaceWindowState['workspaceSpaceAnalysis'],
incoming: SpaceWindowState['workspaceSpaceAnalysis']
): SpaceWindowState['workspaceSpaceAnalysis'] {
if (!current || !incoming) {
return current ?? incoming
}
if (current.scannedAt !== incoming.scannedAt) {
return current.scannedAt > incoming.scannedAt ? current : incoming
}
if (current.worktrees.length !== incoming.worktrees.length) {
return current.worktrees.length < incoming.worktrees.length ? current : incoming
}
const detailCount = (analysis: typeof current): number =>
analysis.worktrees.reduce((total, row) => total + row.topLevelItems.length, 0)
return detailCount(incoming) > detailCount(current) ? incoming : current
}
export function isSpaceWindowState(value: unknown): value is SpaceWindowState {
return (
value !== null &&
typeof value === 'object' &&
'repos' in value &&
Array.isArray(value.repos) &&
'worktreesByRepo' in value &&
typeof value.worktreesByRepo === 'object' &&
'workspaceSpaceAnalysis' in value &&
'settings' in value
)
}
export function spaceWindowStateChanged(a: AppState, b: AppState): boolean {
const before = Object.values(pickSpaceWindowState(a))
const after = Object.values(pickSpaceWindowState(b))
return before.some((value, index) => value !== after[index])
}
+9 -1
View File
@@ -2383,13 +2383,16 @@
},
"WorkspaceSpaceCompactPanel": {
"2837dc7c72": "cancelling",
"9be86c46a0": "Freeable",
"bef4dc0457": "{{value0}} reclaimable · {{value1}} unavailable"
},
"WorkspaceSpaceManagerPanel": {
"02b27c2230": "workspace",
"1cc6cd4c0f": "workspaces",
"433bb7f595": "ok",
"d254f04097": "cancelling"
"83f1a0a932": "Reclaimable",
"d254f04097": "cancelling",
"d595295d7d": "{{value0}} can be reclaimed from linked worktrees."
},
"resource": {
"manager": {
@@ -2678,6 +2681,11 @@
"candidateRow": {
"contextCount": "Context: {{value0}}"
}
},
"space": {
"WorkspaceSpacePage": {
"8d0048e1cb": "Workspace disk usage and reclaimable worktree storage."
}
}
}
},
+33
View File
@@ -20181,5 +20181,38 @@
"clear": "Clear Jira favourite",
"activeLabel": "Jira favourites: {{name}}"
}
},
"workspaceSpace": {
"compact": {
"linkedAndUnavailable": "{{value0}} in linked worktrees · {{value1}} unavailable",
"linkedAndScanned": "{{value0}} in linked worktrees · {{value1}} workspaces",
"linked": "Linked"
},
"savedScan": {
"folderDetailsUnavailable": "Folder details are not saved with past scans. Refresh to inspect this workspace."
},
"empty": {
"title": "See where workspace storage goes",
"description": "Scan your workspaces to compare sizes, inspect their largest folders, and review space you can reclaim.",
"scan": "Scan workspaces"
},
"metric": {
"linkedWorktrees": "Linked worktrees"
},
"summary": {
"linkedWorktrees": "{{value0}} is used by linked worktrees. Managed worktrees can be removed here."
},
"status": {
"notManaged": "Not managed"
},
"chart": {
"folderSizes": "Folder sizes",
"workspaceSizes": "Workspace sizes"
},
"window": {
"close": "Close",
"errorTitle": "Space hit an error.",
"errorDescription": "Close and reopen Space to try again."
}
}
}
+102
View File
@@ -0,0 +1,102 @@
import './assets/main.css'
import { StrictMode, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Toaster } from '@/components/ui/sonner'
import { TooltipProvider } from '@/components/ui/tooltip'
import { ConfirmationDialogProvider } from './components/confirmation-dialog'
import DeleteWorktreeDialog from './components/sidebar/DeleteWorktreeDialog'
import PreservedBranchBatchReviewModal from './components/sidebar/PreservedBranchBatchReviewModal'
import WorkspaceSpacePage from './components/workspace-space/WorkspaceSpacePage'
import {
isSpaceWindowState,
selectSpaceWindowAnalysis
} from './components/workspace-space/space-window-state'
import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary'
import { applyDocumentTheme } from './lib/document-theme'
import { buildAppFontFamily } from './lib/app-font-family'
import { getOrCreateRendererRoot } from './lib/react-renderer-root'
import { I18nProvider } from './i18n/I18nProvider'
import { useAppStore } from './store'
function SpaceWindowRoot(): React.JSX.Element {
const { t } = useTranslation()
const settings = useAppStore((state) => state.settings)
const activeModal = useAppStore((state) => state.activeModal)
useEffect(() => {
const api = window.api.workspaceSpace
const offState = api.onWindowState?.((value) => {
if (!isSpaceWindowState(value)) {
return
}
const currentAnalysis = useAppStore.getState().workspaceSpaceAnalysis
const currentScanning = useAppStore.getState().workspaceSpaceScanning
const incomingAnalysis = value.workspaceSpaceAnalysis
useAppStore.setState({
...value,
workspaceSpaceAnalysis: selectSpaceWindowAnalysis(currentAnalysis, incomingAnalysis),
workspaceSpaceScanning: currentScanning || value.workspaceSpaceScanning,
workspaceSpaceScanProgress: currentScanning
? useAppStore.getState().workspaceSpaceScanProgress
: value.workspaceSpaceScanProgress
})
})
const offProgress = api.onProgress((progress) => {
useAppStore.getState().applyWorkspaceSpaceProgress(progress)
})
void api.requestWindowState?.()
void useAppStore.getState().hydrateWorkspaceSpaceFromCache()
return () => {
offState?.()
offProgress()
}
}, [])
useEffect(() => {
applyDocumentTheme(settings?.theme ?? 'system', { disableTransitions: false })
document.documentElement.style.setProperty(
'--app-font-family',
buildAppFontFamily(settings?.appFontFamily)
)
if (settings?.theme !== 'system') {
return
}
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = (): void => applyDocumentTheme('system')
media.addEventListener('change', handleChange)
return () => media.removeEventListener('change', handleChange)
}, [settings?.theme, settings?.appFontFamily])
return (
<RecoverableRenderErrorBoundary
boundaryId="space-window.root"
surface="app-root"
title={t('workspaceSpace.window.errorTitle', 'Space hit an error.')}
description={t(
'workspaceSpace.window.errorDescription',
'Close and reopen Space to try again.'
)}
>
<TooltipProvider delayDuration={400}>
<ConfirmationDialogProvider>
<WorkspaceSpacePage />
{activeModal === 'delete-worktree' ? <DeleteWorktreeDialog /> : null}
{activeModal === 'preserved-branch-review' ? <PreservedBranchBatchReviewModal /> : null}
</ConfirmationDialogProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
</TooltipProvider>
</RecoverableRenderErrorBoundary>
)
}
const root = document.getElementById('root')
if (!root) {
throw new Error('Space window root not found.')
}
getOrCreateRendererRoot(root, import.meta.hot?.data).render(
<StrictMode>
<I18nProvider>
<SpaceWindowRoot />
</I18nProvider>
</StrictMode>
)
@@ -603,6 +603,23 @@ describe('createUISlice page navigation history', () => {
})
describe('createUISlice space navigation', () => {
it('opens desktop Space without replacing the current view', () => {
const openWindow = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
api: {
ui: { set: vi.fn(() => Promise.resolve()) },
workspaceSpace: { openWindow }
}
})
const store = createUIStore()
store.getState().openTaskPage()
store.getState().openSpacePage()
expect(openWindow).toHaveBeenCalledOnce()
expect(store.getState().activeView).toBe('tasks')
})
it('records Space page opens as workspace cleanup interactions', () => {
const setMock = vi.fn(() => Promise.resolve())
vi.stubGlobal('window', {
@@ -36,6 +36,10 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial<U
})),
openSpacePage: () => {
get().recordFeatureInteraction?.('workspace-cleanup')
if (typeof window !== 'undefined' && window.api?.workspaceSpace?.openWindow) {
void window.api.workspaceSpace.openWindow()
return
}
set((state) => ({
activeView: 'space',
previousViewBeforeSpace: