mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
perf(source-control): virtualize the changed-files list
The Source Control panel mounted every non-collapsed file row, so large changesets (big refactors, lockfiles, branch compare with hundreds of entries) created hundreds to thousands of DOM rows and re-rendered them all on every 3s git-status refresh (`STA-351`, `STA-1280`). Window each per-section list (uncommitted tree/list, committed-on-branch tree/list) with @tanstack/react-virtual against the panel's shared scroller, extracted into source-control-virtual-file-list.tsx. Sections under 50 rows keep the exact pre-virtualization DOM so small changesets see zero change; larger sections mount only viewport + overscan rows. Fixed-height estimates with element measurement keep identical refreshes from moving the scroll position, and stable row keys carry item identity across status polls. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -108,6 +108,7 @@ import {
|
||||
type SourceControlEntryGroups,
|
||||
type SourceControlSectionArea
|
||||
} from './source-control-section-order'
|
||||
import { SourceControlVirtualFileList } from './source-control-virtual-file-list'
|
||||
import {
|
||||
buildActiveOpenFileSignature,
|
||||
buildActiveOpenRowKeys
|
||||
@@ -795,6 +796,10 @@ export function clearRemoteActionErrorsForCompletedConflictOperations({
|
||||
|
||||
function SourceControlInner(): React.JSX.Element {
|
||||
const sourceControlRef = useRef<HTMLDivElement | null>(null)
|
||||
// Why: the changed-file sections virtualize against the panel's shared
|
||||
// scroller rather than owning a nested scroll container. State (not a ref)
|
||||
// so the lists re-render and start observing once the element attaches.
|
||||
const [fileListScrollElement, setFileListScrollElement] = useState<HTMLDivElement | null>(null)
|
||||
const isMac = useMemo(() => navigator.userAgent.includes('Mac'), [])
|
||||
const pendingCommentEditorRevealFrameIdsRef = useRef<number[]>([])
|
||||
// Why: React setState is async, so a rapid double-click on the Commit
|
||||
@@ -5627,6 +5632,7 @@ function SourceControlInner(): React.JSX.Element {
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={setFileListScrollElement}
|
||||
className="relative flex flex-1 flex-col overflow-auto scrollbar-sleek pt-1"
|
||||
style={{ paddingBottom: selectedKeys.size > 0 ? 50 : undefined }}
|
||||
>
|
||||
@@ -5957,8 +5963,12 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
{!isCollapsed &&
|
||||
(sourceControlViewMode === 'tree'
|
||||
? (visibleTreeRowsBySection[id] ?? []).map((node) => {
|
||||
(sourceControlViewMode === 'tree' ? (
|
||||
<SourceControlVirtualFileList
|
||||
rows={visibleTreeRowsBySection[id] ?? []}
|
||||
scrollElement={fileListScrollElement}
|
||||
getRowKey={(node) => node.key}
|
||||
renderRow={(node) => {
|
||||
if (node.type === 'submodule-placeholder') {
|
||||
return (
|
||||
<SubmodulePlaceholderRow
|
||||
@@ -6022,8 +6032,18 @@ function SourceControlInner(): React.JSX.Element {
|
||||
submoduleExpansion={submoduleExpansion}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: (visibleListRowsBySection[id] ?? []).map((row) => {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SourceControlVirtualFileList
|
||||
rows={visibleListRowsBySection[id] ?? []}
|
||||
scrollElement={fileListScrollElement}
|
||||
getRowKey={(row) =>
|
||||
row.type === 'submodule-placeholder'
|
||||
? row.key
|
||||
: `${row.entry.area}::${row.entry.path}`
|
||||
}
|
||||
renderRow={(row) => {
|
||||
if (row.type === 'submodule-placeholder') {
|
||||
return (
|
||||
<SubmodulePlaceholderRow
|
||||
@@ -6066,7 +6086,9 @@ function SourceControlInner(): React.JSX.Element {
|
||||
submoduleExpansion={submoduleExpansion}
|
||||
/>
|
||||
)
|
||||
}))}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -6117,8 +6139,12 @@ function SourceControlInner(): React.JSX.Element {
|
||||
}
|
||||
/>
|
||||
{!collapsedSections.has('branch') &&
|
||||
(sourceControlViewMode === 'tree'
|
||||
? visibleBranchTreeRows.map((node) => {
|
||||
(sourceControlViewMode === 'tree' ? (
|
||||
<SourceControlVirtualFileList
|
||||
rows={visibleBranchTreeRows}
|
||||
scrollElement={fileListScrollElement}
|
||||
getRowKey={(node) => node.key}
|
||||
renderRow={(node) => {
|
||||
if (node.type === 'directory') {
|
||||
return (
|
||||
<SourceControlBranchTreeDirectoryRow
|
||||
@@ -6143,8 +6169,14 @@ function SourceControlInner(): React.JSX.Element {
|
||||
showPathHint={false}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: filteredBranchEntries.map((entry) => (
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<SourceControlVirtualFileList
|
||||
rows={filteredBranchEntries}
|
||||
scrollElement={fileListScrollElement}
|
||||
getRowKey={(entry) => `branch:${entry.path}`}
|
||||
renderRow={(entry) => (
|
||||
<BranchEntryRow
|
||||
key={`branch:${entry.path}`}
|
||||
entry={entry}
|
||||
@@ -6155,7 +6187,9 @@ function SourceControlInner(): React.JSX.Element {
|
||||
onOpen={(event) => openCommittedDiff(entry, event)}
|
||||
commentCount={diffCommentCountByPath.get(entry.path) ?? 0}
|
||||
/>
|
||||
)))}
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
// @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'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { GitStatusEntry } from '../../../../shared/types'
|
||||
import SourceControl from './SourceControl'
|
||||
import {
|
||||
SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
|
||||
SOURCE_CONTROL_FILE_ROW_OVERSCAN,
|
||||
SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS
|
||||
} from './source-control-virtual-file-list'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const activeRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const activeWorktree = {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/wt',
|
||||
head: 'abcdef123',
|
||||
branch: 'refs/heads/feature/virtual-list',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'feature/virtual-list',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
return {
|
||||
activeRepo,
|
||||
activeWorktree,
|
||||
state: {} as Record<string, unknown>
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = Object.assign(
|
||||
(selector?: (state: Record<string, unknown>) => unknown) =>
|
||||
selector ? selector(mocks.state) : mocks.state,
|
||||
{
|
||||
getState: () => mocks.state
|
||||
}
|
||||
)
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
useActiveWorktree: () => mocks.activeWorktree,
|
||||
useRepoById: (repoId: string | null) =>
|
||||
repoId === mocks.activeRepo.id ? mocks.activeRepo : null,
|
||||
useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]])
|
||||
}))
|
||||
|
||||
vi.mock('@/components/confirmation-dialog', () => ({
|
||||
useConfirmationDialog: () => vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
vi.mock('./git-status-refresh', () => ({
|
||||
refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
const VIEWPORT_HEIGHT_PX = 600
|
||||
// Rows the viewport can show plus overscan on both edges plus the partial
|
||||
// rows clipped at each edge of the window.
|
||||
const MAX_MOUNTED_ROWS =
|
||||
Math.ceil(VIEWPORT_HEIGHT_PX / SOURCE_CONTROL_FILE_ROW_HEIGHT_PX) +
|
||||
2 * SOURCE_CONTROL_FILE_ROW_OVERSCAN +
|
||||
2
|
||||
|
||||
function gitEntry(overrides: Partial<GitStatusEntry>): GitStatusEntry {
|
||||
return {
|
||||
path: 'src/file.ts',
|
||||
area: 'unstaged',
|
||||
status: 'modified',
|
||||
added: 1,
|
||||
removed: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function manyEntries(count: number): GitStatusEntry[] {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
gitEntry({ path: `src/file-${String(index).padStart(3, '0')}.ts` })
|
||||
)
|
||||
}
|
||||
|
||||
function noopAsync(value: unknown = undefined): () => Promise<unknown> {
|
||||
return vi.fn().mockResolvedValue(value)
|
||||
}
|
||||
|
||||
function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
||||
vi.clearAllMocks()
|
||||
mocks.state = {
|
||||
activeWorktreeId: mocks.activeWorktree.id,
|
||||
activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' },
|
||||
groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] },
|
||||
repos: [mocks.activeRepo],
|
||||
worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] },
|
||||
rightSidebarOpen: false,
|
||||
rightSidebarTab: 'source-control',
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
|
||||
gitConflictOperationByWorktree: {},
|
||||
remoteStatusesByWorktree: {},
|
||||
isRemoteOperationActive: false,
|
||||
inFlightRemoteOpKind: null,
|
||||
settings: null,
|
||||
hostedReviewCache: {},
|
||||
prCache: {},
|
||||
commitMessageGenerationRecords: {},
|
||||
pullRequestGenerationRecords: {},
|
||||
openFiles: [],
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
getDiffComments: vi.fn(() => []),
|
||||
updateSettings: noopAsync(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
openSettingsPage: vi.fn(),
|
||||
fetchHostedReviewForBranch: noopAsync(),
|
||||
getHostedReviewCreationEligibility: noopAsync(null),
|
||||
createHostedReview: noopAsync({ ok: false, error: 'not available' }),
|
||||
updateWorktreeMeta: noopAsync(),
|
||||
fetchPRForBranch: noopAsync(),
|
||||
enqueueGitHubPRRefresh: vi.fn(),
|
||||
updateRepo: noopAsync(),
|
||||
setGitStatus: vi.fn(),
|
||||
updateWorktreeGitIdentity: vi.fn(),
|
||||
beginGitBranchCompareRequest: vi.fn(() => 'request-key'),
|
||||
setGitBranchCompareResult: vi.fn(),
|
||||
clearGitBranchCompare: vi.fn(),
|
||||
fetchUpstreamStatus: noopAsync(),
|
||||
setUpstreamStatus: vi.fn(),
|
||||
pushBranch: noopAsync(),
|
||||
pullBranch: noopAsync(),
|
||||
fastForwardBranch: noopAsync(),
|
||||
syncBranch: noopAsync(),
|
||||
rebaseFromBase: noopAsync(),
|
||||
fetchBranch: noopAsync(),
|
||||
revealInExplorer: vi.fn(),
|
||||
trackConflictPath: vi.fn(),
|
||||
openDiff: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
setEditorViewMode: vi.fn(),
|
||||
setMarkdownViewMode: vi.fn(),
|
||||
setPendingEditorReveal: vi.fn(),
|
||||
openConflictFile: vi.fn(),
|
||||
openConflictReview: vi.fn(),
|
||||
openBranchDiff: vi.fn(),
|
||||
createEmptySplitGroup: vi.fn(() => 'group-2'),
|
||||
openAllDiffs: vi.fn(),
|
||||
openBranchAllDiffs: vi.fn(),
|
||||
openCommitAllDiffs: vi.fn(),
|
||||
deleteDiffComment: noopAsync(true),
|
||||
clearDiffComments: noopAsync(true),
|
||||
clearDiffCommentsForFile: noopAsync(true),
|
||||
setScrollToDiffCommentId: vi.fn(),
|
||||
setRightSidebarOpen: vi.fn(),
|
||||
setRightSidebarTab: vi.fn(),
|
||||
allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'),
|
||||
setCommitMessageGenerationRecord: vi.fn(),
|
||||
updateCommitMessageGenerationRecord: vi.fn(),
|
||||
pruneCommitMessageGenerationRecords: vi.fn(),
|
||||
allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'),
|
||||
setPullRequestGenerationRecord: vi.fn(),
|
||||
updatePullRequestGenerationRecord: vi.fn(),
|
||||
prunePullRequestGenerationRecords: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetState()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
|
||||
// Why: happy-dom has no layout. Give the virtualizer a 600px scroll viewport
|
||||
// and 24px rows (both rect + row measurement read offsetHeight), and keep
|
||||
// the observer path inert so measurements stay deterministic.
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
return this.classList.contains('overflow-auto')
|
||||
? VIEWPORT_HEIGHT_PX
|
||||
: SOURCE_CONTROL_FILE_ROW_HEIGHT_PX
|
||||
}
|
||||
)
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
// The panel scroller acts as the fixed viewport; everything inside it
|
||||
// shifts up by scrollTop, which is what the list's scroll-margin math
|
||||
// reads. Leaf rows only ever contribute their 24px height.
|
||||
const isScroller = this.classList.contains('overflow-auto')
|
||||
const scroller = isScroller ? null : this.closest('.overflow-auto')
|
||||
const top = isScroller ? 0 : -(scroller?.scrollTop ?? 0)
|
||||
return {
|
||||
top,
|
||||
bottom: top + SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
|
||||
height: SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
|
||||
left: 0,
|
||||
right: 240,
|
||||
width: 240,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function renderSourceControl(): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<SourceControl />
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function scroller(): HTMLDivElement {
|
||||
const element = container.querySelector<HTMLDivElement>('.overflow-auto')
|
||||
if (!element) {
|
||||
throw new Error('source control scroller not found')
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
function scrollTo(offset: number): void {
|
||||
const element = scroller()
|
||||
// Why: happy-dom clamps scrollTop against its zero-height layout; pin the
|
||||
// property so the virtualizer's scroll handler reads the intended offset.
|
||||
Object.defineProperty(element, 'scrollTop', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: offset
|
||||
})
|
||||
act(() => {
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
}
|
||||
|
||||
function mountedRows(): HTMLDivElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLDivElement>('[data-testid="source-control-entry"]')
|
||||
)
|
||||
}
|
||||
|
||||
function row(path: string): HTMLDivElement | null {
|
||||
return container.querySelector<HTMLDivElement>(
|
||||
`[data-source-control-path="${path}"][data-source-control-area="unstaged"]`
|
||||
)
|
||||
}
|
||||
|
||||
function virtualList(): HTMLDivElement | null {
|
||||
return container.querySelector<HTMLDivElement>('[data-testid="source-control-virtual-list"]')
|
||||
}
|
||||
|
||||
describe('SourceControl virtualized changed-files list', () => {
|
||||
it('bounds mounted rows by viewport + overscan with 500 entries', () => {
|
||||
resetState({
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(virtualList()).toBeTruthy()
|
||||
const mounted = mountedRows().length
|
||||
expect(mounted).toBeGreaterThan(0)
|
||||
expect(mounted).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
})
|
||||
|
||||
it('windows rows while scrolling instead of mounting the tail', () => {
|
||||
resetState({
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(row('src/file-000.ts')).toBeTruthy()
|
||||
expect(row('src/file-250.ts')).toBeNull()
|
||||
|
||||
scrollTo(250 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
|
||||
|
||||
expect(row('src/file-250.ts')).toBeTruthy()
|
||||
expect(row('src/file-000.ts')).toBeNull()
|
||||
expect(mountedRows().length).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
})
|
||||
|
||||
it('keeps row selection across the virtualized boundary', () => {
|
||||
resetState({
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
const target = row('src/file-000.ts')
|
||||
expect(target).toBeTruthy()
|
||||
act(() => {
|
||||
target?.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true }))
|
||||
})
|
||||
expect(row('src/file-000.ts')?.className).toContain('bg-accent/60')
|
||||
expect(container.textContent).toContain('1 selected')
|
||||
|
||||
// The selected row scrolls out of the mounted window but stays selected.
|
||||
scrollTo(240 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
|
||||
expect(row('src/file-000.ts')).toBeNull()
|
||||
expect(container.textContent).toContain('1 selected')
|
||||
|
||||
scrollTo(0)
|
||||
expect(row('src/file-000.ts')?.className).toContain('bg-accent/60')
|
||||
})
|
||||
|
||||
it('collapses and re-expands a virtualized section', () => {
|
||||
resetState({
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
|
||||
})
|
||||
renderSourceControl()
|
||||
expect(mountedRows().length).toBeGreaterThan(0)
|
||||
|
||||
const changesHeader = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Changes')
|
||||
)
|
||||
expect(changesHeader).toBeTruthy()
|
||||
|
||||
act(() => {
|
||||
changesHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
expect(mountedRows().length).toBe(0)
|
||||
expect(virtualList()).toBeNull()
|
||||
|
||||
act(() => {
|
||||
changesHeader?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
expect(mountedRows().length).toBeGreaterThan(0)
|
||||
expect(mountedRows().length).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
})
|
||||
|
||||
it('keeps the scroll offset and window when a refresh delivers identical entries', () => {
|
||||
resetState({
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: manyEntries(500) }
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
scrollTo(100 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
|
||||
expect(row('src/file-100.ts')).toBeTruthy()
|
||||
const heightBefore = virtualList()?.style.height
|
||||
|
||||
// A status refresh rebuilds every entry object with identical content.
|
||||
mocks.state = {
|
||||
...mocks.state,
|
||||
gitStatusByWorktree: {
|
||||
[mocks.activeWorktree.id]: manyEntries(500).map((entry) => ({ ...entry }))
|
||||
}
|
||||
}
|
||||
renderSourceControl()
|
||||
|
||||
expect(scroller().scrollTop).toBe(100 * SOURCE_CONTROL_FILE_ROW_HEIGHT_PX)
|
||||
expect(row('src/file-100.ts')).toBeTruthy()
|
||||
expect(virtualList()?.style.height).toBe(heightBefore)
|
||||
})
|
||||
|
||||
it('renders small changesets in full without the virtualization container', () => {
|
||||
const paths = ['src/a.ts', 'src/b.ts', 'src/c.ts']
|
||||
resetState({
|
||||
gitStatusByWorktree: {
|
||||
[mocks.activeWorktree.id]: paths.map((path) => gitEntry({ path }))
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(paths.length).toBeLessThan(SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS)
|
||||
expect(virtualList()).toBeNull()
|
||||
expect(mountedRows().length).toBe(paths.length)
|
||||
for (const path of paths) {
|
||||
expect(row(path)).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds mounted tree-view rows for large changesets', () => {
|
||||
resetState({
|
||||
settings: { sourceControlViewMode: 'tree' },
|
||||
gitStatusByWorktree: {
|
||||
[mocks.activeWorktree.id]: Array.from({ length: 500 }, (_, index) =>
|
||||
gitEntry({
|
||||
path: `src/dir-${String(Math.floor(index / 20)).padStart(2, '0')}/file-${String(
|
||||
index
|
||||
).padStart(3, '0')}.ts`
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(virtualList()).toBeTruthy()
|
||||
const mounted = container.querySelectorAll(
|
||||
'[data-testid="source-control-virtual-list"] [data-index]'
|
||||
).length
|
||||
expect(mounted).toBeGreaterThan(0)
|
||||
expect(mounted).toBeLessThanOrEqual(MAX_MOUNTED_ROWS)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
|
||||
// Why: below this count plain rows keep the DOM identical to the
|
||||
// pre-virtualization markup (natural flow, no absolute positioning), so small
|
||||
// changesets keep exact scrollbar and flicker-free behavior. `STA-351` /
|
||||
// `STA-1280` jank only appears with hundreds of rows.
|
||||
export const SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS = 50
|
||||
// Why: rows are one py-1 text-xs line, except conflict/submodule rows which
|
||||
// add a second label line — so estimate the common height and let
|
||||
// measureElement correct the tall variants. Identical entries measure
|
||||
// identically, so a git-status refresh cannot move the scroll position.
|
||||
export const SOURCE_CONTROL_FILE_ROW_HEIGHT_PX = 24
|
||||
export const SOURCE_CONTROL_FILE_ROW_OVERSCAN = 10
|
||||
|
||||
/**
|
||||
* Windows one source-control section's rows inside the panel's shared
|
||||
* scroller. Sections below SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS render plainly;
|
||||
* larger ones mount only viewport + overscan rows.
|
||||
*/
|
||||
export function SourceControlVirtualFileList<TRow>({
|
||||
rows,
|
||||
getRowKey,
|
||||
renderRow,
|
||||
scrollElement
|
||||
}: {
|
||||
rows: readonly TRow[]
|
||||
getRowKey: (row: TRow) => string
|
||||
renderRow: (row: TRow) => React.ReactNode
|
||||
// Why: a state-held element, not a ref — ancestor host refs are not attached
|
||||
// yet when this component's mount effects run, so a ref would leave the
|
||||
// virtualizer unobserved until some unrelated re-render.
|
||||
scrollElement: HTMLDivElement | null
|
||||
}): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [scrollMargin, setScrollMargin] = useState(0)
|
||||
const virtualize = rows.length >= SOURCE_CONTROL_VIRTUALIZE_MIN_ROWS
|
||||
|
||||
// Why: the section shares the panel scroller with the commit area and
|
||||
// sibling sections, so the virtualizer needs this list's offset inside that
|
||||
// scroller. Everything above changes height only through React renders
|
||||
// (commit drafts, banners, section collapse), so a per-render layout read
|
||||
// keeps the margin current without observers. The guarded setState makes the
|
||||
// measure/render loop converge in one extra pass.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- no dependency list on purpose: any sibling render can move this list inside the shared scroller.
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || !scrollElement) {
|
||||
return
|
||||
}
|
||||
const nextMargin = Math.round(
|
||||
container.getBoundingClientRect().top -
|
||||
scrollElement.getBoundingClientRect().top +
|
||||
scrollElement.scrollTop
|
||||
)
|
||||
setScrollMargin((current) => (current === nextMargin ? current : nextMargin))
|
||||
})
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
enabled: virtualize && scrollElement !== null,
|
||||
getScrollElement: () => scrollElement,
|
||||
estimateSize: () => SOURCE_CONTROL_FILE_ROW_HEIGHT_PX,
|
||||
overscan: SOURCE_CONTROL_FILE_ROW_OVERSCAN,
|
||||
scrollMargin,
|
||||
// Why: stable row keys let the virtualizer carry item identity across
|
||||
// status refreshes instead of remounting the window each poll.
|
||||
getItemKey: (index) => {
|
||||
const row = rows[index]
|
||||
return row === undefined ? index : getRowKey(row)
|
||||
}
|
||||
})
|
||||
|
||||
if (!virtualize) {
|
||||
return <>{rows.map((row) => renderRow(row))}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid="source-control-virtual-list"
|
||||
className="relative w-full"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const row = rows[item.index]
|
||||
if (row === undefined) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={item.index}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
// Why: item.start includes scrollMargin (offsets are scroller-wide),
|
||||
// but rows position inside this container, so subtract it back out.
|
||||
style={{ transform: `translateY(${item.start - scrollMargin}px)` }}
|
||||
>
|
||||
{renderRow(row)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user