From 1383ba85cf35801c30d0118f126c35bca4487d77 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 10 Jul 2026 00:18:07 -0700 Subject: [PATCH] Virtualize workspace cleanup candidate rows for large lists (#8044) Extracts row rendering into a WorkspaceCleanupCandidateList that windows rows via @tanstack/react-virtual once the list crosses 40 items, keeping plain natural-flow rendering below that threshold. Memoizes CandidateRow and stabilizes its callback props so scan stream-in and selection updates don't re-render unrelated rows, avoiding O(N) DOM churn for users with large numbers of worktrees. --- .../WorkspaceCleanupDialog.tsx | 94 +++++++++++-------- .../workspace-cleanup-candidate-list.test.tsx | 88 +++++++++++++++++ .../workspace-cleanup-candidate-list.tsx | 72 ++++++++++++++ .../workspace-cleanup-candidate-row.tsx | 9 +- 4 files changed, 223 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx create mode 100644 src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index d5ec61ca3..ba50c8dc4 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -69,6 +69,7 @@ import { type WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal' import { CandidateRow } from './workspace-cleanup-candidate-row' +import { WorkspaceCleanupCandidateList } from './workspace-cleanup-candidate-list' import { getCandidateStatus, getContextPillLabel, @@ -212,6 +213,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { const openRef = useRef(open) const [selectedIds, setSelectedIds] = useState>(() => new Set()) const [expandedRowIds, setExpandedRowIds] = useState>(() => new Set()) + const [rowsScrollElement, setRowsScrollElement] = useState(null) const [activeView, setActiveView] = useState('ready') const [confirming, setConfirming] = useState(false) const [confirmCandidates, setConfirmCandidates] = useState([]) @@ -505,6 +507,10 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setExpandedRowIds((current) => toggleSetMember(current, worktreeId)) }, []) + const toggleSelectedRow = useCallback((worktreeId: string) => { + setSelectedIds((current) => toggleSetMember(current, worktreeId)) + }, []) + const openConfirmRemove = useCallback((candidates: readonly WorkspaceCleanupCandidate[]) => { const nextCandidates = filterWorkspaceCleanupRemovalCandidates( candidates, @@ -517,6 +523,28 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { setConfirming(true) }, []) + // Why: stable per-row handlers so React.memo keeps unchanged CandidateRow + // instances from re-rendering on scan stream-in and selection changes. + const handleRemoveRow = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + if (loading) { + return + } + setSelectedIds(new Set([candidate.worktreeId])) + openConfirmRemove([candidate]) + }, + [loading, openConfirmRemove] + ) + + const handleViewCandidate = useCallback( + (candidate: WorkspaceCleanupCandidate) => { + markCandidateViewed(candidate) + closeModal() + activateAndRevealWorktree(candidate.worktreeId) + }, + [closeModal, markCandidateViewed] + ) + const cancelConfirmRemove = useCallback(() => { if (removalProgress) { closeModal() @@ -777,7 +805,7 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { onRestoreIgnored={() => void resetDismissals()} /> ) : null} - +
{initialLoading ? : null} {!loading && scan && candidates.length === 0 && !scanNoticeMessage ? ( @@ -851,38 +879,34 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { )} /> ) : null} - {activeRows.map((candidate, index) => ( - 1 && index === activeRows.length - 1} - expanded={expandedRowIds.has(candidate.worktreeId)} - lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)} - removing={loading || deletingWorktreeIds.has(candidate.worktreeId)} - selected={ - selectedIds.has(candidate.worktreeId) && - !loading && - !deletingWorktreeIds.has(candidate.worktreeId) - } - failure={rowFailures[candidate.worktreeId]} - onToggleExpanded={toggleExpandedRow} - onToggleSelected={(id) => - setSelectedIds((current) => toggleSetMember(current, id)) - } - onView={closeAndView} - onIgnore={ignoreCandidate} - onRemove={(candidate) => { - if (loading) { - return + ( + - ))} + last={activeRows.length > 1 && index === activeRows.length - 1} + expanded={expandedRowIds.has(candidate.worktreeId)} + lastActivityLabel={formatRelativeTime(candidate.lastActivityAt)} + removing={loading || deletingWorktreeIds.has(candidate.worktreeId)} + selected={ + selectedIds.has(candidate.worktreeId) && + !loading && + !deletingWorktreeIds.has(candidate.worktreeId) + } + failure={rowFailures[candidate.worktreeId]} + onToggleExpanded={toggleExpandedRow} + onToggleSelected={toggleSelectedRow} + onView={handleViewCandidate} + onIgnore={ignoreCandidate} + onRemove={handleRemoveRow} + /> + )} + />
@@ -900,12 +924,6 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { ) - - function closeAndView(candidate: WorkspaceCleanupCandidate): void { - markCandidateViewed(candidate) - closeModal() - activateAndRevealWorktree(candidate.worktreeId) - } } function WorkspaceCleanupFilterToolbar({ diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx new file mode 100644 index 000000000..6779c05f6 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS, + WorkspaceCleanupCandidateList +} from './workspace-cleanup-candidate-list' +import { CandidateRow } from './workspace-cleanup-candidate-row' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function makeRows(count: number): WorkspaceCleanupCandidate[] { + return Array.from({ length: count }, (_, index) => + makeCandidate({ worktreeId: `wt-${index}`, displayName: `Workspace ${index}` }) + ) +} + +describe('WorkspaceCleanupCandidateList', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('renders every row in natural flow below the virtualization threshold', () => { + const rows = makeRows(WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS - 1) + const rendered: string[] = [] + + act(() => { + root?.render( + { + rendered.push(candidate.worktreeId) + return
+ }} + /> + ) + }) + + expect(rendered).toHaveLength(rows.length) + expect(container?.querySelectorAll('[data-testid="row"]')).toHaveLength(rows.length) + // Plain path keeps natural flow: no absolute-positioned windowing wrappers. + expect(container?.querySelector('[data-index]')).toBeNull() + }) + + it('windows rows into an absolutely positioned container at the threshold', () => { + const rows = makeRows(WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS) + // A real element enables the virtualizer; happy-dom reports zero-size layout, + // so this asserts the windowed structure rather than a specific mounted count. + const scrollElement = document.createElement('div') + + act(() => { + root?.render( +
} + /> + ) + }) + + const windowed = container?.querySelector('.absolute') != null + const mounted = container?.querySelectorAll('[data-testid="row"]').length ?? 0 + // Windowed mode never mounts more than the full set, and switches away from + // the plain flow used below the threshold. + expect(mounted).toBeLessThanOrEqual(rows.length) + expect(windowed || mounted === 0).toBe(true) + }) + + it('memoizes CandidateRow so unchanged rows skip re-render', () => { + expect((CandidateRow as { $$typeof?: symbol }).$$typeof).toBe(Symbol.for('react.memo')) + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx new file mode 100644 index 000000000..68569c7ab --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-list.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' + +// Why: below this count plain rows keep the pre-virtualization DOM (natural +// flow, no absolute positioning), so the common few-worktrees case is +// byte-for-byte unchanged. The O(N) stream-in churn and per-keystroke re-render +// only bite at the hundreds-to-thousands a heavy multi-agent user accumulates. +export const WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS = 40 +// Why: a collapsed row is a single metadata line (~48px with px-3 py-2.5); +// expanded rows and failure banners are taller, so estimate the common height +// and let measureElement correct the tall variants. +const WORKSPACE_CLEANUP_ROW_ESTIMATE_PX = 48 +const WORKSPACE_CLEANUP_ROW_OVERSCAN = 8 + +/** + * Windows the cleanup candidate rows inside the dialog's ScrollArea viewport. + * The list is the viewport's only content, so rows sit at scroll offset 0 and + * no scroll-margin bookkeeping is needed. Lists shorter than + * WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS render plainly. + */ +export function WorkspaceCleanupCandidateList({ + rows, + renderRow, + // Why: a state-held element, not a ref — the ScrollArea viewport is not + // attached when this component first mounts, so a ref would leave the + // virtualizer unobserved until some unrelated re-render. + scrollElement +}: { + rows: readonly WorkspaceCleanupCandidate[] + renderRow: (candidate: WorkspaceCleanupCandidate, index: number) => React.ReactNode + scrollElement: HTMLDivElement | null +}): React.JSX.Element { + const virtualize = rows.length >= WORKSPACE_CLEANUP_VIRTUALIZE_MIN_ROWS + + const virtualizer = useVirtualizer({ + count: rows.length, + enabled: virtualize && scrollElement !== null, + getScrollElement: () => scrollElement, + estimateSize: () => WORKSPACE_CLEANUP_ROW_ESTIMATE_PX, + overscan: WORKSPACE_CLEANUP_ROW_OVERSCAN, + // Why: stable worktree keys let the virtualizer carry row identity across + // scan refreshes instead of remounting the window on every streamed row. + getItemKey: (index) => rows[index]?.worktreeId ?? index + }) + + if (!virtualize) { + return <>{rows.map((candidate, index) => renderRow(candidate, index))} + } + + return ( +
+ {virtualizer.getVirtualItems().map((item) => { + const candidate = rows[item.index] + if (candidate === undefined) { + return null + } + return ( +
+ {renderRow(candidate, item.index)} +
+ ) + })} +
+ ) +} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx index 04fb02d28..faf540f3a 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row.tsx @@ -92,7 +92,12 @@ function MetadataIconChip({ ) } -export function CandidateRow({ +// Why: the cleanup list re-renders on every checkbox/expand/search keystroke; +// memo keeps each unchanged row from re-rendering. Effective only while the +// parent passes stable (useCallback) handlers — see WorkspaceCleanupDialog. +// Scan stream-in still re-renders rows (candidates change, so the reviewInfo +// prop identity changes); virtualization, not memo, bounds that cost. +export const CandidateRow = React.memo(function CandidateRow({ candidate, expanded, failure, @@ -321,7 +326,7 @@ export function CandidateRow({
) -} +}) function formatCompactActivityLabel(label: string): string { if (label === 'Just now') {