From fcfbaed3915918fb37f3b28cbeae5b48fee3dda9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 07:27:38 -0700 Subject: [PATCH] fix: avoid workspace space max spread overflow --- .../WorkspaceSpaceManagerPanel.test.tsx | 205 ++++++++++++++++++ .../status-bar/WorkspaceSpaceManagerPanel.tsx | 5 +- .../workspace-space-presentation.ts | 12 + 3 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.test.tsx diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.test.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.test.tsx new file mode 100644 index 00000000000..17abd3271a3 --- /dev/null +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.test.tsx @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + WorkspaceSpaceAnalysis, + WorkspaceSpaceWorktree +} from '../../../../shared/workspace-space-types' + +type EffectCallback = () => void | (() => void) +type StateUpdater = T | ((current: T) => T) + +const hookRuntime = vi.hoisted(() => ({ + effects: [] as EffectCallback[], + index: 0, + values: [] as unknown[] +})) + +const storeBox = vi.hoisted(() => ({ + state: {} as Record +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { + ...actual, + useCallback: (callback: T) => callback, + useEffect: (effect: EffectCallback) => { + hookRuntime.effects.push(effect) + }, + useMemo: (factory: () => T) => factory(), + useRef: (initialValue: T) => { + const index = hookRuntime.index + hookRuntime.index += 1 + if (hookRuntime.values[index] === undefined) { + hookRuntime.values[index] = { current: initialValue } + } + return hookRuntime.values[index] as { current: T } + }, + useState: (initialValue: T | (() => T)) => { + const index = hookRuntime.index + hookRuntime.index += 1 + if (hookRuntime.values[index] === undefined) { + hookRuntime.values[index] = + typeof initialValue === 'function' ? (initialValue as () => T)() : initialValue + } + const setState = (value: StateUpdater): void => { + const current = hookRuntime.values[index] as T + hookRuntime.values[index] = + typeof value === 'function' ? (value as (current: T) => T)(current) : value + } + return [hookRuntime.values[index] as T, setState] as const + } + } +}) + +vi.mock('../../store', () => { + const useAppStore = ((selector: (state: Record) => unknown) => + selector(storeBox.state)) as { + (selector: (state: Record) => unknown): unknown + getState: () => Record + } + useAppStore.getState = () => storeBox.state + return { useAppStore } +}) + +vi.mock('../../store/slices/hosted-review', () => ({ + getHostedReviewCacheKey: ( + repoPath: string, + branch: string, + settings?: { activeRuntimeEnvironmentId?: string | null } | null, + repoId?: string | null + ) => `${settings?.activeRuntimeEnvironmentId ?? 'local'}::${repoId ?? repoPath}::${branch}` +})) + +vi.mock('@/lib/agent-status', () => ({ + detectAgentStatusFromTitle: vi.fn(() => null), + isExplicitAgentStatusFresh: vi.fn(() => true) +})) + +vi.mock('@/lib/tab-has-live-pty', () => ({ + tabHasLivePty: vi.fn(() => false) +})) + +vi.mock('@/lib/utils', () => ({ + cn: (...values: unknown[]) => values.filter(Boolean).join(' ') +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('../right-sidebar/git-status-refresh', () => ({ + refreshGitStatusForWorktree: vi.fn() +})) + +vi.mock('../sidebar/delete-worktree-flow', () => ({ + runWorktreeBatchDelete: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() } +})) + +import { WorkspaceSpaceManagerPanel } from './WorkspaceSpaceManagerPanel' + +function makeWorktree(index: number): WorkspaceSpaceWorktree { + return { + worktreeId: `wt-${index}`, + repoId: 'repo', + repoDisplayName: 'repo', + repoPath: '/repo', + displayName: `workspace-${index}`, + path: `/workspaces/workspace-${index}`, + branch: 'refs/heads/main', + isMainWorktree: true, + isRemote: false, + isSparse: false, + canDelete: false, + lastActivityAt: 0, + status: 'ok', + error: null, + scannedAt: 0, + sizeBytes: index + 1, + reclaimableBytes: 0, + skippedEntryCount: 0, + topLevelItems: [], + omittedTopLevelItemCount: 0, + omittedTopLevelSizeBytes: 0 + } +} + +function makeAnalysis(worktrees: WorkspaceSpaceWorktree[]): WorkspaceSpaceAnalysis { + return { + scannedAt: 1, + totalSizeBytes: worktrees.reduce((sum, worktree) => sum + worktree.sizeBytes, 0), + reclaimableBytes: 0, + worktreeCount: worktrees.length, + scannedWorktreeCount: worktrees.length, + unavailableWorktreeCount: 0, + repos: [ + { + repoId: 'repo', + displayName: 'repo', + path: '/repo', + isRemote: false, + worktreeCount: worktrees.length, + scannedWorktreeCount: worktrees.length, + unavailableWorktreeCount: 0, + totalSizeBytes: 0, + reclaimableBytes: 0, + error: null + } + ], + worktrees + } +} + +function seedStore(worktrees: WorkspaceSpaceWorktree[]): void { + storeBox.state = { + activeWorktreeId: null, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserTabsByWorktree: {}, + cancelWorkspaceSpaceScan: vi.fn(), + deleteStateByWorktreeId: {}, + editorDrafts: {}, + fetchUpstreamStatus: vi.fn(), + gitStatusByWorktree: {}, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + migrationUnsupportedByPtyId: {}, + openFiles: [], + ptyIdsByTabId: {}, + refreshWorkspaceSpace: vi.fn(), + remoteStatusesByWorktree: {}, + removeWorkspaceSpaceWorktrees: vi.fn(), + removeWorktree: vi.fn().mockResolvedValue({ ok: true }), + repos: [], + retainedAgentsByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + setGitStatus: vi.fn(), + setUpstreamStatus: vi.fn(), + settings: {}, + tabsByWorktree: {}, + updateWorktreeGitIdentity: vi.fn(), + workspaceSpaceAnalysis: makeAnalysis(worktrees), + workspaceSpaceScanError: null, + workspaceSpaceScanProgress: null, + workspaceSpaceScanning: false, + worktreesByRepo: {} + } +} + +describe('WorkspaceSpaceManagerPanel', () => { + beforeEach(() => { + hookRuntime.effects = [] + hookRuntime.index = 0 + hookRuntime.values = [] + }) + + it('does not overflow when scanned workspace results contain many rows', () => { + seedStore(Array.from({ length: 130_000 }, (_, index) => makeWorktree(index))) + + expect(() => WorkspaceSpaceManagerPanel()).not.toThrow() + }) +}) diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index adf5a28f2e8..3016550f246 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -69,6 +69,7 @@ import { getSelectedDeletableWorkspaceIds, getVisibleDeletableWorkspaceIds, getWorkspaceSpaceGitStatusRefreshCandidates, + getWorkspaceSpaceMaxSize, isWorkspaceSpaceRowReadyToDelete, sortWorkspaceSpaceRows, type WorkspaceSpaceSortDirection, @@ -831,7 +832,7 @@ function BreakdownList({ ) } - const maxChildSize = Math.max(...worktree.topLevelItems.map((item) => item.sizeBytes), 0) + const maxChildSize = getWorkspaceSpaceMaxSize(worktree.topLevelItems) const topLevelItemCount = worktree.topLevelItems.length + worktree.omittedTopLevelItemCount return (
@@ -1276,7 +1277,7 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { const zoomedWorktree = sourceRows.find((row) => row.worktreeId === treemapZoomWorktreeId && row.status === 'ok') ?? null - const maxSize = Math.max(...rows.map((row) => row.sizeBytes), 0) + const maxSize = getWorkspaceSpaceMaxSize(rows) const selectedDeletableIds = useMemo( () => getSelectedDeletableWorkspaceIds(rows, selectedIds, isWorktreeUnavailableForDelete), [isWorktreeUnavailableForDelete, rows, selectedIds] 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 5a8edcd35f9..cfc6c3e0f90 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -193,6 +193,18 @@ export function filterWorkspaceSpaceRows( }) } +export function getWorkspaceSpaceMaxSize( + items: readonly Pick[] +): number { + let maxSize = 0 + for (const item of items) { + if (item.sizeBytes > maxSize) { + maxSize = item.sizeBytes + } + } + return maxSize +} + export function isWorkspaceSpaceRowReadyToDelete( worktree: WorkspaceSpaceWorktree, readiness: WorkspaceSpaceDeleteReadiness | undefined