From 7f8faae8005cb5aed4cd17ef39cf125d92baf762 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:25:19 -0700 Subject: [PATCH] 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 --- .../build-plugins/main-build-entry-points.ts | 1 + .../build-plugins/plain-node-entry-guard.ts | 1 + .../process-host-direct-import-baseline.txt | 1 - electron.vite.config.ts | 5 +- .../register-core-handlers.test.ts | 8 ++ .../register-core-handlers.ts | 2 + src/main/ipc/workspace-space-window.ts | 83 ++++++++++++++ src/main/startup/main-process-i18n-menu.ts | 7 +- src/main/window/createMainWindow.ts | 2 + src/main/window/workspace-space-window.ts | 98 +++++++++++++++++ .../workspace-space-analysis-capacity.test.ts | 5 + ...orkspace-space-analysis-du-timeout.test.ts | 65 +++++------ src/main/workspace-space-analysis.test.ts | 63 ++++++++++- src/main/workspace-space-analysis.ts | 76 ++++++------- src/main/workspace-space-local-scan.ts | 57 +++++----- src/main/workspace-space-repo-scan.ts | 48 ++++++--- .../workspace-space-scan-worker-client.ts | 65 +++++++++++ src/main/workspace-space-scan-worker-entry.ts | 33 ++++++ .../workspace-space-scan-worker-protocol.ts | 15 +++ src/preload/api/workspace-cleanup-api.ts | 16 +++ src/preload/api/workspace-space-bridge.ts | 51 ++++++++- src/renderer/space.html | 11 ++ src/renderer/src/App.tsx | 2 + .../status-bar/WorkspaceSpaceCompactPanel.tsx | 13 +-- ...use-workspace-space-decision-projection.ts | 12 ++- .../use-workspace-space-manager-bindings.ts | 2 + .../use-workspace-space-manager-panel.ts | 11 +- .../use-workspace-space-manager-projection.ts | 9 +- .../workspace-space-breakdown-list.test.tsx | 5 +- .../workspace-space-breakdown-list.tsx | 7 ++ .../workspace-space-decision-details.ts | 9 +- .../workspace-space-manager-overview.tsx | 39 +++++-- .../workspace-space-manager-table.tsx | 14 ++- .../workspace-space-manager-toolbar.tsx | 2 +- .../workspace-space-presentation.test.ts | 6 +- .../workspace-space-presentation.ts | 2 + .../workspace-space-status-badge.tsx | 7 ++ .../status-bar/workspace-space-treemap.tsx | 31 +++--- .../SpaceWindowStatePublisher.test.tsx | 93 ++++++++++++++++ .../SpaceWindowStatePublisher.tsx | 68 ++++++++++++ .../workspace-space/WorkspaceSpacePage.tsx | 28 +++-- .../space-window-state.test.ts | 67 ++++++++++++ .../workspace-space/space-window-state.ts | 81 ++++++++++++++ .../src/i18n/en-runtime-required.json | 10 +- src/renderer/src/i18n/locales/en.json | 33 ++++++ src/renderer/src/space.tsx | 102 ++++++++++++++++++ .../store/slices/ui-page-navigation.test.ts | 17 +++ .../store/slices/ui/ui-slice-view-actions.ts | 4 + 48 files changed, 1202 insertions(+), 185 deletions(-) create mode 100644 src/main/ipc/workspace-space-window.ts create mode 100644 src/main/window/workspace-space-window.ts create mode 100644 src/main/workspace-space-scan-worker-client.ts create mode 100644 src/main/workspace-space-scan-worker-entry.ts create mode 100644 src/main/workspace-space-scan-worker-protocol.ts create mode 100644 src/renderer/space.html create mode 100644 src/renderer/src/components/workspace-space/SpaceWindowStatePublisher.test.tsx create mode 100644 src/renderer/src/components/workspace-space/SpaceWindowStatePublisher.tsx create mode 100644 src/renderer/src/components/workspace-space/space-window-state.test.ts create mode 100644 src/renderer/src/components/workspace-space/space-window-state.ts create mode 100644 src/renderer/src/space.tsx diff --git a/config/build-plugins/main-build-entry-points.ts b/config/build-plugins/main-build-entry-points.ts index a1c9427ef6a..f190b71313d 100644 --- a/config/build-plugins/main-build-entry-points.ts +++ b/config/build-plugins/main-build-entry-points.ts @@ -32,6 +32,7 @@ export const MAIN_BUILD_ENTRY_POINTS: Record = { // 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' diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index fdd9e7d14ca..7c8a9d894b6 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.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' diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index baf9a9106a8..9fa44942708 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -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 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 443c2e2b2e9..ba586220103 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -95,10 +95,8 @@ function createStartupDiagnosticsBanner(chunkName: string): string { return '""' } } - 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') } } diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index f0af5ba47b5..dace75e6f83 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -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) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index a125d9f98fb..cc256168d74 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -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 diff --git a/src/main/ipc/workspace-space-window.ts b/src/main/ipc/workspace-space-window.ts new file mode 100644 index 00000000000..ae312b5107b --- /dev/null +++ b/src/main/ipc/workspace-space-window.ts @@ -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) + } + }) +} diff --git a/src/main/startup/main-process-i18n-menu.ts b/src/main/startup/main-process-i18n-menu.ts index acde65f4e65..77a19a3ba20 100644 --- a/src/main/startup/main-process-i18n-menu.ts +++ b/src/main/startup/main-process-i18n-menu.ts @@ -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 { // 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') } }, diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 93e4483268b..201ef12e308 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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() diff --git a/src/main/window/workspace-space-window.ts b/src/main/window/workspace-space-window.ts new file mode 100644 index 00000000000..fe53bea7beb --- /dev/null +++ b/src/main/window/workspace-space-window.ts @@ -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() +} diff --git a/src/main/workspace-space-analysis-capacity.test.ts b/src/main/workspace-space-analysis-capacity.test.ts index 5de2931823e..cb4c1784620 100644 --- a/src/main/workspace-space-analysis-capacity.test.ts +++ b/src/main/workspace-space-analysis-capacity.test.ts @@ -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 { diff --git a/src/main/workspace-space-analysis-du-timeout.test.ts b/src/main/workspace-space-analysis-du-timeout.test.ts index da8d237d37b..3c00993ec74 100644 --- a/src/main/workspace-space-analysis-du-timeout.test.ts +++ b/src/main/workspace-space-analysis-du-timeout.test.ts @@ -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 }) diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index 98d4ead1090..cad0f48b05c 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -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 + ) => { + 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', diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index 08c8ab7a3bf..97cc2b046c4 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -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 { - const sizes = new Map() - for (const line of stdout.split('\n')) { - const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line - if (!normalizedLine) { - continue - } - const match = /^(\d+)\s+(.+)$/.exec(normalizedLine) - if (!match) { - continue - } - sizes.set(normalizeLocalDuPath(match[2]), Number(match[1]) * 1024) - } - return sizes -} - -async function readLocalDuDepthOne( - rootPath: string, - signal?: AbortSignal -): Promise> { - const stdout = await new Promise((resolve, reject) => { +async function readLocalDuDepthOne(rootPath: string, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { let settled = false - let child: ReturnType | undefined + let child: ReturnType | undefined let onAbort: (() => void) | null = null let timer: ReturnType | 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) diff --git a/src/main/workspace-space-local-scan.ts b/src/main/workspace-space-local-scan.ts index 4e37ce9e891..31a561c121a 100644 --- a/src/main/workspace-space-local-scan.ts +++ b/src/main/workspace-space-local-scan.ts @@ -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> +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 { + const sizes = new Map() + 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, - normalizeDuPath: (path: string) => string, signal?: AbortSignal ): Promise { throwIfWorkspaceSpaceScanAborted(signal) @@ -93,8 +106,7 @@ async function scanLocalWorktreeWithDu( repo: Repo, worktree: Worktree, scannedAt: number, - readDu: ReadLocalDuDepthOne, - normalizeDuPath: (path: string) => string, + duSizes: Map, signal?: AbortSignal ): Promise { 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 { throwIfWorkspaceSpaceScanAborted(signal) - if (platform !== 'win32') { + if (platform !== 'win32' && duOutput !== null) { try { return await scanLocalWorktreeWithDu( repo, worktree, scannedAt, - readDu, - normalizeDuPath, + parseDuOutput(duOutput), signal ) } catch (error) { diff --git a/src/main/workspace-space-repo-scan.ts b/src/main/workspace-space-repo-scan.ts index 370b803f345..e920d70c5ac 100644 --- a/src/main/workspace-space-repo-scan.ts +++ b/src/main/workspace-space-repo-scan.ts @@ -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 }): Promise { 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, { diff --git a/src/main/workspace-space-scan-worker-client.ts b/src/main/workspace-space-scan-worker-client.ts new file mode 100644 index 00000000000..f5ee30ec546 --- /dev/null +++ b/src/main/workspace-space-scan-worker-client.ts @@ -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 { + 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 +} diff --git a/src/main/workspace-space-scan-worker-entry.ts b/src/main/workspace-space-scan-worker-entry.ts new file mode 100644 index 00000000000..784c6d79392 --- /dev/null +++ b/src/main/workspace-space-scan-worker-entry.ts @@ -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) + } + ) +}) diff --git a/src/main/workspace-space-scan-worker-protocol.ts b/src/main/workspace-space-scan-worker-protocol.ts new file mode 100644 index 00000000000..48ff9fab3bf --- /dev/null +++ b/src/main/workspace-space-scan-worker-protocol.ts @@ -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 } diff --git a/src/preload/api/workspace-cleanup-api.ts b/src/preload/api/workspace-cleanup-api.ts index 9f224efd88b..f2d579f8bc1 100644 --- a/src/preload/api/workspace-cleanup-api.ts +++ b/src/preload/api/workspace-cleanup-api.ts @@ -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 cancel: () => Promise onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void + openWindow?: () => Promise + closeWindow?: () => Promise + publishWindowState?: (state: unknown) => Promise + requestWindowState?: () => Promise + onWindowStateRequested?: (callback: () => void) => () => void + onWindowClosed?: (callback: () => void) => () => void + onWindowState?: (callback: (state: unknown) => void) => () => void + revealWorktree?: (worktreeId: string, executionHostId?: ExecutionHostId) => Promise + onRevealWorktree?: ( + callback: (worktreeId: string, executionHostId?: ExecutionHostId) => void + ) => () => void + requestWorktreeRefresh?: () => Promise + onWorktreeRefreshRequested?: (callback: () => void) => () => void + publishWindowAnalysis?: (analysis: WorkspaceSpaceAnalysis) => Promise + onWindowAnalysis?: (callback: (analysis: WorkspaceSpaceAnalysis) => void) => () => void } diff --git a/src/preload/api/workspace-space-bridge.ts b/src/preload/api/workspace-space-bridge.ts index ec9bc74e12a..3904c6ae424 100644 --- a/src/preload/api/workspace-space-bridge.ts +++ b/src/preload/api/workspace-space-bridge.ts @@ -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, diff --git a/src/renderer/space.html b/src/renderer/space.html new file mode 100644 index 00000000000..617ea525cca --- /dev/null +++ b/src/renderer/space.html @@ -0,0 +1,11 @@ + + + + + Orca Space + + +
+ + + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1ed1194a66b..72bee5eb1b3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 { + 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({
- {translate( - 'auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0', - 'Freeable' - )} + {translate('workspaceSpace.compact.linked', 'Linked')}
{formatBytes(analysis.reclaimableBytes)} diff --git a/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.ts b/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.ts index 9903076842e..b567a2cc36a 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-decision-projection.ts @@ -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 @@ -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) => diff --git a/src/renderer/src/components/status-bar/use-workspace-space-manager-bindings.ts b/src/renderer/src/components/status-bar/use-workspace-space-manager-bindings.ts index e97b0c39f54..1eb790d8ef2 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-manager-bindings.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-manager-bindings.ts @@ -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, diff --git a/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts b/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts index eb960ef634d..9380c30b8b7 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-manager-panel.ts @@ -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) diff --git a/src/renderer/src/components/status-bar/use-workspace-space-manager-projection.ts b/src/renderer/src/components/status-bar/use-workspace-space-manager-projection.ts index 02363fe33bb..b28472b0795 100644 --- a/src/renderer/src/components/status-bar/use-workspace-space-manager-projection.ts +++ b/src/renderer/src/components/status-bar/use-workspace-space-manager-projection.ts @@ -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( diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx index 52f1f167e50..bd1902c781d 100644 --- a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.test.tsx @@ -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( { ) 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.') }) }) diff --git a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx index 01530e17ba8..9f082bce9a8 100644 --- a/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-breakdown-list.tsx @@ -109,6 +109,13 @@ export function WorkspaceSpaceBreakdownList({ 'No files found.' )}
+ ) : worktree.topLevelItems.length === 0 ? ( +
+ {translate( + 'workspaceSpace.savedScan.folderDetailsUnavailable', + 'Folder details are not saved with past scans. Refresh to inspect this workspace.' + )} +
) : (
diff --git a/src/renderer/src/components/status-bar/workspace-space-decision-details.ts b/src/renderer/src/components/status-bar/workspace-space-decision-details.ts index 2265d0e9a4a..b85df924934 100644 --- a/src/renderer/src/components/status-bar/workspace-space-decision-details.ts +++ b/src/renderer/src/components/status-bar/workspace-space-decision-details.ts @@ -37,6 +37,7 @@ export type WorkspaceDecisionDetails = { export type WorkspaceDecisionInputs = { repoMap: ReadonlyMap worktreeMap: ReadonlyMap + worktreeRecord?: Worktree repos?: readonly Repo[] tabsByWorktree: Record ptyIdsByTabId: Record @@ -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( diff --git a/src/renderer/src/components/status-bar/workspace-space-manager-overview.tsx b/src/renderer/src/components/status-bar/workspace-space-manager-overview.tsx index b17951a45f6..b36cc3e885a 100644 --- a/src/renderer/src/components/status-bar/workspace-space-manager-overview.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-manager-overview.tsx @@ -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 @@ -31,6 +32,29 @@ export function WorkspaceSpaceManagerOverview({ sourceRows, zoomedWorktree } = model + if (!analysis && !isScanning) { + return ( +
+
+ +
+

+ {translate('workspaceSpace.empty.title', 'See where workspace storage goes')} +

+

+ {translate( + 'workspaceSpace.empty.description', + 'Scan your workspaces to compare sizes, inspect their largest folders, and review space you can reclaim.' + )} +

+ {scanError ?

{scanError}

: null} + +
+ ) + } return ( <>
@@ -42,10 +66,7 @@ export function WorkspaceSpaceManagerOverview({ value={analysis ? formatBytes(analysis.totalSizeBytes) : '—'} /> +
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({
- ) : ( + ) : !analysis && !scanError ? null : (
{scanError ? translate( diff --git a/src/renderer/src/components/status-bar/workspace-space-manager-toolbar.tsx b/src/renderer/src/components/status-bar/workspace-space-manager-toolbar.tsx index 2e5d6fec160..2bcc2888ea4 100644 --- a/src/renderer/src/components/status-bar/workspace-space-manager-toolbar.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-manager-toolbar.tsx @@ -32,7 +32,7 @@ export function WorkspaceSpaceManagerToolbar({ } = model return ( <> - {hasRows ? ( + {selectedDeletableIds.length > 0 ? (
diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 08b4b4bc9d9..c9b212a2320 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -115,6 +115,7 @@ function worktreeRecord(overrides: Partial = {}): 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) }) diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts index b88332e8a3f..277171a167c 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -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 && diff --git a/src/renderer/src/components/status-bar/workspace-space-status-badge.tsx b/src/renderer/src/components/status-bar/workspace-space-status-badge.tsx index b685d8905f3..5ff49ec255e 100644 --- a/src/renderer/src/components/status-bar/workspace-space-status-badge.tsx +++ b/src/renderer/src/components/status-bar/workspace-space-status-badge.tsx @@ -115,6 +115,13 @@ export function WorkspaceSpaceStatusBadge({ ) } + if (decisionDetails?.canOpenWorkspace === false) { + return ( + + {translate('workspaceSpace.status.notManaged', 'Not managed')} + + ) + } return ( 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 ( -
+
+
+ {zoomedWorktree + ? translate('workspaceSpace.chart.folderSizes', 'Folder sizes') + : translate('workspaceSpace.chart.workspaceSizes', 'Workspace sizes')} +
{zoomedWorktree ? ( <> @@ -122,7 +129,7 @@ export function WorkspaceSpaceTreemap({
@@ -62,7 +72,7 @@ export default function WorkspaceSpacePage(): React.JSX.Element {

{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.' )}

diff --git a/src/renderer/src/components/workspace-space/space-window-state.test.ts b/src/renderer/src/components/workspace-space/space-window-state.test.ts new file mode 100644 index 00000000000..a43805d1e2e --- /dev/null +++ b/src/renderer/src/components/workspace-space/space-window-state.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/workspace-space/space-window-state.ts b/src/renderer/src/components/workspace-space/space-window-state.ts new file mode 100644 index 00000000000..1f4af0dd59c --- /dev/null +++ b/src/renderer/src/components/workspace-space/space-window-state.ts @@ -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 + +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]) +} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index bff569ccec5..b96d707c339 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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." + } } } }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7b0417aee55..3f49fe6a309 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." + } } } diff --git a/src/renderer/src/space.tsx b/src/renderer/src/space.tsx new file mode 100644 index 00000000000..63df601302a --- /dev/null +++ b/src/renderer/src/space.tsx @@ -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 ( + + + + + {activeModal === 'delete-worktree' ? : null} + {activeModal === 'preserved-branch-review' ? : null} + + + + + ) +} + +const root = document.getElementById('root') +if (!root) { + throw new Error('Space window root not found.') +} +getOrCreateRendererRoot(root, import.meta.hot?.data).render( + + + + + +) diff --git a/src/renderer/src/store/slices/ui-page-navigation.test.ts b/src/renderer/src/store/slices/ui-page-navigation.test.ts index c61a0e69a48..2abc4a334b5 100644 --- a/src/renderer/src/store/slices/ui-page-navigation.test.ts +++ b/src/renderer/src/store/slices/ui-page-navigation.test.ts @@ -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', { diff --git a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts index b97f77c89a3..ccece2d27c8 100644 --- a/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts +++ b/src/renderer/src/store/slices/ui/ui-slice-view-actions.ts @@ -36,6 +36,10 @@ export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial { get().recordFeatureInteraction?.('workspace-cleanup') + if (typeof window !== 'undefined' && window.api?.workspaceSpace?.openWindow) { + void window.api.workspaceSpace.openWindow() + return + } set((state) => ({ activeView: 'space', previousViewBeforeSpace: