feat(ui): unify list virtualization on @tanstack/react-virtual and polish scroll behavior
- Migrate sidebar session groups, git changes panel, virtualized code blocks, and JSON tree viewer from virtua to @tanstack/react-virtual; virtua remains only inside the Pierre diff viewer integration - Sidebar: preserve scroll position when virtualization enables mid-session (enable only once the ancestor scroll element is resolved, seed initial offset from its live scrollTop, render plain rows for the single pre-paint frame); disable native scroll anchoring on the sessions scroller; keep row spacing identical between plain and virtualized modes; absolute row positioning so variable-height rows cannot drift past the container - Chat: expand tool/thinking blocks downward by only adjusting scroll for rows growing above the viewport; raise the desktop history-load lead to 1.5 viewports so prepends land above the visible area - Git changes: compute the prefetch window from the first visible row, skipping overscan rows above the viewport - Sidebar rows: make the whole highlighted row area clickable, guarded against double-firing from interactive children
This commit is contained in:
@@ -50,7 +50,7 @@ const TANSTACK_MOBILE_OVERSCAN = 16;
|
||||
const resolveTanstackOverscan = (): number => (
|
||||
isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN
|
||||
);
|
||||
// Post-prepend anchor hold (upstream parity): measurements of freshly
|
||||
// Post-prepend anchor hold: measurements of freshly
|
||||
// prepended rows settle over multiple frames, so a single restore can be
|
||||
// invalidated by the next measurement pass. Re-assert the anchor until it
|
||||
// holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES.
|
||||
@@ -61,6 +61,8 @@ const ANCHOR_HOLD_MAX_FRAMES = 180;
|
||||
const TANSTACK_ESTIMATE_MIN_SAMPLES = 5;
|
||||
const TANSTACK_ESTIMATE_MIN = 120;
|
||||
const TANSTACK_ESTIMATE_MAX = 1200;
|
||||
// "At bottom" tolerance for resize-adjustment decisions.
|
||||
const TANSTACK_AT_END_THRESHOLD_PX = 80;
|
||||
|
||||
// Quiet-window prepend on mobile: while a touch drag or momentum scroll is
|
||||
// active, iOS owns the scroll position and ANY geometry change above the
|
||||
@@ -1098,7 +1100,7 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef,
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
// Expose the new total height before core writes an anchor
|
||||
// correction so the browser does not clamp the offset to the old
|
||||
// height (upstream parity).
|
||||
// height.
|
||||
const sizeElement = sizeContainerRef.current;
|
||||
if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`;
|
||||
elementScroll(offset, options, instance);
|
||||
@@ -1111,6 +1113,17 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef,
|
||||
initialOffset: () => Number.MAX_SAFE_INTEGER,
|
||||
initialMeasurementsCache: initialMeasurements,
|
||||
});
|
||||
// Only compensate scroll for rows growing ABOVE the viewport (history
|
||||
// remeasures, prepended pages). A row growing inside the viewport —
|
||||
// expanding a tool call or thinking block — must grow DOWNWARD naturally;
|
||||
// the end-anchored default made it expand upward. At the bottom,
|
||||
// app-level auto-follow owns pinning, so skip there too instead of
|
||||
// double-writing. (This is an instance field, not a constructor option.)
|
||||
tanstackVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||
if (instance.isAtEnd(TANSTACK_AT_END_THRESHOLD_PX)) return false;
|
||||
const firstVisibleIndex = instance.range?.startIndex;
|
||||
return firstVisibleIndex !== undefined && item.index < firstVisibleIndex;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTanstack) return;
|
||||
|
||||
@@ -59,7 +59,17 @@ export interface UseChatTimelineControllerResult {
|
||||
}
|
||||
|
||||
const TURN_MODEL_CACHE_MAX = 30
|
||||
const HISTORY_SCROLL_THRESHOLD = 200
|
||||
// Desktop load-older lead distance. Trigger well before the top: the fetch
|
||||
// then completes and the prepend lands ABOVE the viewport, where key-anchored
|
||||
// compensation is exact and invisible. A short lead (the old 200px) let the
|
||||
// user reach the estimated-height region near the absolute top mid-fetch,
|
||||
// where the post-insert restore is least precise and reads as a small jump.
|
||||
const HISTORY_SCROLL_THRESHOLD_MIN_PX = 1200
|
||||
const HISTORY_SCROLL_VIEWPORT_FACTOR = 1.5
|
||||
const resolveHistoryScrollThreshold = (clientHeight: number): number => Math.max(
|
||||
HISTORY_SCROLL_THRESHOLD_MIN_PX,
|
||||
clientHeight * HISTORY_SCROLL_VIEWPORT_FACTOR,
|
||||
)
|
||||
const VSCODE_TURN_MODEL_CACHE_MAX = 4
|
||||
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
||||
const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
||||
@@ -692,7 +702,7 @@ export const useChatTimelineController = ({
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
if (isPinnedRef.current) return;
|
||||
if (container.scrollTop >= HISTORY_SCROLL_THRESHOLD) return;
|
||||
if (container.scrollTop >= resolveHistoryScrollThreshold(container.clientHeight)) return;
|
||||
if (!historySignalsRef.current.canLoadEarlier) return;
|
||||
if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Renders large code/read outputs without mounting one highlighter per line:
|
||||
* 1. ONE worker tokenization of the whole block (off the main thread)
|
||||
* 2. virtua to only render visible rows
|
||||
* 2. @tanstack/react-virtual to only render visible rows
|
||||
*
|
||||
* Tokenizing the whole block at once also preserves cross-line syntax context
|
||||
* (multi-line strings/comments) that per-line highlighting loses. Colors resolve
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Virtualizer } from 'virtua';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||
@@ -114,28 +114,41 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`;
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: lines.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 20,
|
||||
});
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="typography-code font-mono w-full min-w-0"
|
||||
style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
<Virtualizer
|
||||
data={lines}
|
||||
itemSize={ROW_HEIGHT}
|
||||
bufferSize={ROW_HEIGHT * 20}
|
||||
scrollRef={parentRef}
|
||||
>
|
||||
{(line, index) => (
|
||||
<Row
|
||||
key={index}
|
||||
line={line}
|
||||
html={highlighted?.[index]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
)}
|
||||
</Virtualizer>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualItems.map((item) => {
|
||||
const line = lines[item.index];
|
||||
if (!line) return null;
|
||||
return (
|
||||
<div
|
||||
key={item.index}
|
||||
data-index={item.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
<Row
|
||||
line={line}
|
||||
html={highlighted?.[item.index]}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={lineStyles?.(line)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Virtualizer } from 'virtua';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
// Archived buckets routinely grow into the hundreds/thousands; virtualize
|
||||
@@ -581,7 +581,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
});
|
||||
|
||||
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const archivedScrollRef = React.useRef<HTMLElement | null>(null);
|
||||
const [archivedScrollEl, setArchivedScrollEl] = React.useState<HTMLElement | null>(null);
|
||||
// Offset of the virtual container from the scroll element's content origin.
|
||||
// virtua reads startMargin from Virtualizer options and uses it
|
||||
@@ -617,7 +616,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
React.useLayoutEffect(() => {
|
||||
if (!shouldVirtualize) {
|
||||
if (archivedScrollEl !== null) setArchivedScrollEl(null);
|
||||
archivedScrollRef.current = null;
|
||||
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
|
||||
return;
|
||||
}
|
||||
@@ -632,7 +630,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
if (providedScrollEl && providedScrollEl.contains(container)) {
|
||||
scrollEl = providedScrollEl;
|
||||
if (scrollEl !== archivedScrollEl) {
|
||||
archivedScrollRef.current = scrollEl;
|
||||
setArchivedScrollEl(scrollEl);
|
||||
return;
|
||||
}
|
||||
@@ -649,7 +646,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
el = el.parentElement;
|
||||
}
|
||||
if (scrollEl !== archivedScrollEl) {
|
||||
archivedScrollRef.current = scrollEl;
|
||||
setArchivedScrollEl(scrollEl);
|
||||
return;
|
||||
}
|
||||
@@ -661,6 +657,31 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset));
|
||||
});
|
||||
|
||||
// The scroll element is an ANCESTOR of this section (the sidebar's
|
||||
// ScrollableOverlay), so scrollMargin translates its scrollTop into
|
||||
// container-relative coordinates — the tanstack equivalent of virtua's
|
||||
// startMargin this replaces.
|
||||
// Enable ONLY once the ancestor scroll element is resolved. While the
|
||||
// virtualizer is disabled the core resets its cached scroll offset, so the
|
||||
// first enabled read takes initialOffset() from the LIVE scrollTop below —
|
||||
// making the core's attach-time scrollTo target the current position (a
|
||||
// visual no-op) instead of a stale 0 that reset the sidebar to the top.
|
||||
// The core only learns the offset from scroll events after that, so this
|
||||
// initial seeding is what makes the first render window correct too.
|
||||
const virtualizerReady = shouldVirtualize && archivedScrollEl !== null;
|
||||
const sessionVirtualizer = useVirtualizer<HTMLElement, HTMLDivElement>({
|
||||
count: visibleSessions.length,
|
||||
enabled: virtualizerReady,
|
||||
getScrollElement: () => archivedScrollEl,
|
||||
initialOffset: () => archivedScrollEl?.scrollTop ?? 0,
|
||||
estimateSize: () => ARCHIVED_ROW_ESTIMATE_PX,
|
||||
// Expanded parents render children inline and dwarf the row estimate;
|
||||
// widen the window so their extra height stays covered.
|
||||
overscan: hasExpandedParent ? 20 : 8,
|
||||
scrollMargin: archivedScrollMargin,
|
||||
getItemKey: (index) => visibleSessions[index]?.session.id ?? index,
|
||||
});
|
||||
|
||||
// Hooks below MUST stay above the search-empty early-return so they
|
||||
// fire in the same order every render — rules-of-hooks.
|
||||
const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => {
|
||||
@@ -913,21 +934,63 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
{renderFolderItems()}
|
||||
{shouldVirtualize ? (
|
||||
<div ref={archivedVirtualContainerRef}>
|
||||
<Virtualizer
|
||||
data={visibleSessions}
|
||||
itemSize={ARCHIVED_ROW_ESTIMATE_PX}
|
||||
bufferSize={hasExpandedParent ? ARCHIVED_ROW_ESTIMATE_PX * 20 : ARCHIVED_ROW_ESTIMATE_PX * 8}
|
||||
scrollRef={archivedScrollRef}
|
||||
startMargin={archivedScrollMargin}
|
||||
>
|
||||
{(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
{!virtualizerReady ? (
|
||||
// At most one pre-paint frame: this wrapper must exist for the
|
||||
// layout effect to resolve the ancestor scroll element, which
|
||||
// re-renders synchronously before paint. Rendering the plain rows
|
||||
// meanwhile keeps the container's height real so the scroller
|
||||
// never collapses/clamps during the flip.
|
||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
childRenderExtrasFor,
|
||||
}) as React.ReactElement}
|
||||
</Virtualizer>
|
||||
}))
|
||||
) : (
|
||||
<div style={{ height: sessionVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{/* Absolutely positioned rows (canonical tanstack layout): with
|
||||
variable-height rows, flow-stacking can drift from the computed
|
||||
total height until measurements settle and overlap the content
|
||||
below the group. Per-item offsets cannot drift. item.start
|
||||
includes scrollMargin (ancestor-scroll offset), so subtract it. */}
|
||||
{sessionVirtualizer.getVirtualItems().map((item) => {
|
||||
const node = visibleSessions[item.index];
|
||||
if (!node) return null;
|
||||
return (
|
||||
<div
|
||||
key={node.session.id}
|
||||
data-index={item.index}
|
||||
ref={sessionVirtualizer.measureElement}
|
||||
// Rows carry my-0.5 (2px), which COLLAPSES to 2px between
|
||||
// neighbors in normal flow but cannot collapse across
|
||||
// isolated virtualized wrappers — spacing doubles to 4px the
|
||||
// moment virtualization kicks in. Replace the row margin
|
||||
// with 1px per side (no collapse, 1+1 = the same visual 2px
|
||||
// gap). The [data-session-row] selector reaches the row
|
||||
// through the dnd/context-menu wrappers at any depth and
|
||||
// keeps nested child rows consistent too.
|
||||
className="[&_[data-session-row]]:my-px"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${item.start - archivedScrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
subtreeContainsActive,
|
||||
subtreeContainsEditing,
|
||||
menuOpenSessionId,
|
||||
nodeStructureKey: resolveNodeStructureKey(node),
|
||||
childRenderExtrasFor,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||
|
||||
@@ -747,6 +747,18 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
handleSessionSelect(session.id, sessionDirectory, projectId);
|
||||
};
|
||||
|
||||
// The selection/active highlight covers the WHOLE row box (gutter, edge
|
||||
// paddings), while the primary click target is the inner title button.
|
||||
// Make the rest of the highlighted box clickable too — but only for clicks
|
||||
// that did not originate from an interactive child (title button, chevron,
|
||||
// action menu), so nothing double-fires.
|
||||
const handleRowBackgroundClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.defaultPrevented) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('button, a, input, [role="menuitem"], [role="menu"]')) return;
|
||||
handleRowSelect(event as unknown as React.MouseEvent<HTMLButtonElement>);
|
||||
};
|
||||
|
||||
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (event.button === 2 || (event.button === 0 && event.ctrlKey && !selectionModeEnabled)) {
|
||||
suppressNextSelectRef.current = true;
|
||||
@@ -974,8 +986,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
data-session-row={session.id}
|
||||
data-session-scope={sessionDirectory ?? ''}
|
||||
data-session-archived={archivedBucket ? '1' : '0'}
|
||||
onClick={handleRowBackgroundClick}
|
||||
className={cn(
|
||||
'group relative my-0.5 flex items-center rounded-md py-1 pr-1.5',
|
||||
'group relative my-0.5 flex cursor-pointer items-center rounded-md py-1 pr-1.5',
|
||||
// Pull the row box left into the container gutter so the
|
||||
// selection highlight covers the chevron/status markers
|
||||
// (which sit in that gutter), then re-pad so the title text
|
||||
|
||||
@@ -142,7 +142,12 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>
|
||||
// [overflow-anchor:none] — the browser's native scroll anchoring otherwise
|
||||
// latches onto content BELOW a growing session group (e.g. the "Show more"
|
||||
// button) and holds it in place, which makes newly revealed sessions look
|
||||
// like they insert upward. With anchoring off, scrollTop stays put and new
|
||||
// rows appear below naturally.
|
||||
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}>
|
||||
{props.topContent}
|
||||
{props.showOnlyMainWorkspace ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Virtualizer } from 'virtua';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import {
|
||||
parseJsonToTree,
|
||||
@@ -203,6 +203,14 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: ()
|
||||
|
||||
const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD;
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: flatNodes.length,
|
||||
enabled: shouldVirtualize,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 20,
|
||||
getItemKey: (index) => flatNodes[index]?.node.id ?? index,
|
||||
});
|
||||
|
||||
const handleToggle = React.useCallback((id: string) => {
|
||||
setCollapsedPaths((prev) => {
|
||||
@@ -238,21 +246,26 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: ()
|
||||
className={className}
|
||||
style={{ maxHeight, overflow: 'auto' }}
|
||||
>
|
||||
<Virtualizer
|
||||
data={flatNodes}
|
||||
itemSize={ROW_HEIGHT}
|
||||
bufferSize={ROW_HEIGHT * 20}
|
||||
scrollRef={parentRef}
|
||||
>
|
||||
{(flatNode) => (
|
||||
<JsonRow
|
||||
key={flatNode.node.id}
|
||||
flatNode={flatNode}
|
||||
onToggle={handleToggle}
|
||||
onCopyPath={onCopyPath}
|
||||
/>
|
||||
)}
|
||||
</Virtualizer>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const flatNode = flatNodes[item.index];
|
||||
if (!flatNode) return null;
|
||||
return (
|
||||
<div
|
||||
key={flatNode.node.id}
|
||||
data-index={item.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
<JsonRow
|
||||
flatNode={flatNode}
|
||||
onToggle={handleToggle}
|
||||
onCopyPath={onCopyPath}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Virtualizer, type VirtualizerHandle } from 'virtua';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -195,16 +195,26 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
|
||||
|
||||
const rowCount = rows.length;
|
||||
const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const rowVirtualizerRef = React.useRef<VirtualizerHandle | null>(null);
|
||||
const [visibleStartIndex, setVisibleStartIndex] = React.useState(0);
|
||||
|
||||
const updateVisibleStartIndex = React.useCallback((offset: number) => {
|
||||
const virtualizer = rowVirtualizerRef.current;
|
||||
const next = virtualizer
|
||||
? virtualizer.findItemIndex(offset)
|
||||
: Math.floor(offset / CHANGE_ROW_ESTIMATE_PX);
|
||||
setVisibleStartIndex((previous) => (previous === next ? previous : next));
|
||||
}, []);
|
||||
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: rowCount,
|
||||
enabled: shouldVirtualize,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
|
||||
overscan: 12,
|
||||
getItemKey: (index) => rows[index]?.key ?? index,
|
||||
});
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
// First VISIBLE row index drives the visible-path prefetch window (the
|
||||
// virtua findItemIndex/onScroll pair this replaces). virtualRows starts at
|
||||
// the overscan boundary — up to `overscan` rows above the viewport — so
|
||||
// skip rows that end above the current scroll offset; otherwise the
|
||||
// prefetch budget leaks to offscreen files above the viewport.
|
||||
const visibleStartIndex = React.useMemo(() => {
|
||||
if (!shouldVirtualize) return 0;
|
||||
const scrollTop = scrollRef.current?.scrollTop ?? 0;
|
||||
const firstVisible = virtualRows.find((item) => item.end > scrollTop);
|
||||
return firstVisible?.index ?? 0;
|
||||
}, [shouldVirtualize, virtualRows, scrollRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onVisiblePathsChange) {
|
||||
@@ -481,27 +491,34 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
|
||||
className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
{shouldVirtualize ? (
|
||||
<Virtualizer
|
||||
ref={rowVirtualizerRef}
|
||||
data={rows}
|
||||
itemSize={CHANGE_ROW_ESTIMATE_PX}
|
||||
bufferSize={CHANGE_ROW_ESTIMATE_PX * 12}
|
||||
scrollRef={scrollRef}
|
||||
onScroll={updateVisibleStartIndex}
|
||||
>
|
||||
{(row, index) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={cn(
|
||||
'relative',
|
||||
showDivider(index) &&
|
||||
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||
)}
|
||||
>
|
||||
{renderRow(row, index === 0)}
|
||||
</div>
|
||||
)}
|
||||
</Virtualizer>
|
||||
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{/* Absolutely positioned rows: variable-height rows can drift from
|
||||
the computed total height under flow stacking until measured. */}
|
||||
{virtualRows.map((item) => {
|
||||
const row = rows[item.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
data-index={item.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${item.start}px)`,
|
||||
}}
|
||||
className={cn(
|
||||
showDivider(item.index) &&
|
||||
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
||||
)}
|
||||
>
|
||||
{renderRow(row, item.index === 0)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div role="list" aria-label={t('gitView.changes.changedFilesAria')}>
|
||||
{rows.map((row, index) => (
|
||||
|
||||
Reference in New Issue
Block a user