mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user