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 => (
|
const resolveTanstackOverscan = (): number => (
|
||||||
isMobileSurfaceRuntime() ? TANSTACK_MOBILE_OVERSCAN : TANSTACK_OVERSCAN
|
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
|
// prepended rows settle over multiple frames, so a single restore can be
|
||||||
// invalidated by the next measurement pass. Re-assert the anchor until it
|
// invalidated by the next measurement pass. Re-assert the anchor until it
|
||||||
// holds still for STABLE_FRAMES consecutive frames, giving up at MAX_FRAMES.
|
// 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_SAMPLES = 5;
|
||||||
const TANSTACK_ESTIMATE_MIN = 120;
|
const TANSTACK_ESTIMATE_MIN = 120;
|
||||||
const TANSTACK_ESTIMATE_MAX = 1200;
|
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
|
// 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
|
// 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) => {
|
scrollToFn: (offset, options, instance) => {
|
||||||
// Expose the new total height before core writes an anchor
|
// Expose the new total height before core writes an anchor
|
||||||
// correction so the browser does not clamp the offset to the old
|
// correction so the browser does not clamp the offset to the old
|
||||||
// height (upstream parity).
|
// height.
|
||||||
const sizeElement = sizeContainerRef.current;
|
const sizeElement = sizeContainerRef.current;
|
||||||
if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`;
|
if (sizeElement) sizeElement.style.height = `${instance.getTotalSize()}px`;
|
||||||
elementScroll(offset, options, instance);
|
elementScroll(offset, options, instance);
|
||||||
@@ -1111,6 +1113,17 @@ const StaticHistoryList = React.memo(({ entries, engine, contentRef, scrollRef,
|
|||||||
initialOffset: () => Number.MAX_SAFE_INTEGER,
|
initialOffset: () => Number.MAX_SAFE_INTEGER,
|
||||||
initialMeasurementsCache: initialMeasurements,
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!isTanstack) return;
|
if (!isTanstack) return;
|
||||||
|
|||||||
@@ -59,7 +59,17 @@ export interface UseChatTimelineControllerResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TURN_MODEL_CACHE_MAX = 30
|
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 = 4
|
||||||
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
||||||
const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
||||||
@@ -692,7 +702,7 @@ export const useChatTimelineController = ({
|
|||||||
const container = scrollRef.current;
|
const container = scrollRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
if (isPinnedRef.current) 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 (!historySignalsRef.current.canLoadEarlier) return;
|
||||||
if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;
|
if (isLoadingOlderRef.current || pendingRevealWorkRef.current) return;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Renders large code/read outputs without mounting one highlighter per line:
|
* Renders large code/read outputs without mounting one highlighter per line:
|
||||||
* 1. ONE worker tokenization of the whole block (off the main thread)
|
* 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
|
* Tokenizing the whole block at once also preserves cross-line syntax context
|
||||||
* (multi-line strings/comments) that per-line highlighting loses. Colors resolve
|
* (multi-line strings/comments) that per-line highlighting loses. Colors resolve
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Virtualizer } from 'virtua';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownTheme';
|
||||||
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
|
||||||
@@ -114,28 +114,41 @@ const VirtualizedRows: React.FC<VirtualizedRowsProps> = React.memo(({
|
|||||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||||
const viewportHeight = `min(${lines.length * ROW_HEIGHT}px, ${maxHeight})`;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
className="typography-code font-mono w-full min-w-0"
|
className="typography-code font-mono w-full min-w-0"
|
||||||
style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
|
style={{ ...(syntaxVars as React.CSSProperties), height: viewportHeight, maxHeight, overflow: 'auto' }}
|
||||||
>
|
>
|
||||||
<Virtualizer
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||||
data={lines}
|
{virtualItems.map((item) => {
|
||||||
itemSize={ROW_HEIGHT}
|
const line = lines[item.index];
|
||||||
bufferSize={ROW_HEIGHT * 20}
|
if (!line) return null;
|
||||||
scrollRef={parentRef}
|
return (
|
||||||
>
|
<div
|
||||||
{(line, index) => (
|
key={item.index}
|
||||||
<Row
|
data-index={item.index}
|
||||||
key={index}
|
ref={virtualizer.measureElement}
|
||||||
line={line}
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.start}px)` }}
|
||||||
html={highlighted?.[index]}
|
>
|
||||||
showLineNumbers={showLineNumbers}
|
<Row
|
||||||
style={lineStyles?.(line)}
|
line={line}
|
||||||
/>
|
html={highlighted?.[item.index]}
|
||||||
)}
|
showLineNumbers={showLineNumbers}
|
||||||
</Virtualizer>
|
style={lineStyles?.(line)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Virtualizer } from 'virtua';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
// Archived buckets routinely grow into the hundreds/thousands; virtualize
|
// 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 archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const archivedScrollRef = React.useRef<HTMLElement | null>(null);
|
|
||||||
const [archivedScrollEl, setArchivedScrollEl] = React.useState<HTMLElement | null>(null);
|
const [archivedScrollEl, setArchivedScrollEl] = React.useState<HTMLElement | null>(null);
|
||||||
// Offset of the virtual container from the scroll element's content origin.
|
// Offset of the virtual container from the scroll element's content origin.
|
||||||
// virtua reads startMargin from Virtualizer options and uses it
|
// virtua reads startMargin from Virtualizer options and uses it
|
||||||
@@ -617,7 +616,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (!shouldVirtualize) {
|
if (!shouldVirtualize) {
|
||||||
if (archivedScrollEl !== null) setArchivedScrollEl(null);
|
if (archivedScrollEl !== null) setArchivedScrollEl(null);
|
||||||
archivedScrollRef.current = null;
|
|
||||||
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
|
if (archivedScrollMargin !== 0) setArchivedScrollMargin(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -632,7 +630,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
if (providedScrollEl && providedScrollEl.contains(container)) {
|
if (providedScrollEl && providedScrollEl.contains(container)) {
|
||||||
scrollEl = providedScrollEl;
|
scrollEl = providedScrollEl;
|
||||||
if (scrollEl !== archivedScrollEl) {
|
if (scrollEl !== archivedScrollEl) {
|
||||||
archivedScrollRef.current = scrollEl;
|
|
||||||
setArchivedScrollEl(scrollEl);
|
setArchivedScrollEl(scrollEl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -649,7 +646,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
el = el.parentElement;
|
el = el.parentElement;
|
||||||
}
|
}
|
||||||
if (scrollEl !== archivedScrollEl) {
|
if (scrollEl !== archivedScrollEl) {
|
||||||
archivedScrollRef.current = scrollEl;
|
|
||||||
setArchivedScrollEl(scrollEl);
|
setArchivedScrollEl(scrollEl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -661,6 +657,31 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
setArchivedScrollMargin((prev) => (Math.abs(prev - offset) < 1 ? prev : offset));
|
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
|
// Hooks below MUST stay above the search-empty early-return so they
|
||||||
// fire in the same order every render — rules-of-hooks.
|
// fire in the same order every render — rules-of-hooks.
|
||||||
const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => {
|
const collectGroupSessions = React.useCallback((nodes: SessionNode[]): Session[] => {
|
||||||
@@ -913,21 +934,63 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
{renderFolderItems()}
|
{renderFolderItems()}
|
||||||
{shouldVirtualize ? (
|
{shouldVirtualize ? (
|
||||||
<div ref={archivedVirtualContainerRef}>
|
<div ref={archivedVirtualContainerRef}>
|
||||||
<Virtualizer
|
{!virtualizerReady ? (
|
||||||
data={visibleSessions}
|
// At most one pre-paint frame: this wrapper must exist for the
|
||||||
itemSize={ARCHIVED_ROW_ESTIMATE_PX}
|
// layout effect to resolve the ancestor scroll element, which
|
||||||
bufferSize={hasExpandedParent ? ARCHIVED_ROW_ESTIMATE_PX * 20 : ARCHIVED_ROW_ESTIMATE_PX * 8}
|
// re-renders synchronously before paint. Rendering the plain rows
|
||||||
scrollRef={archivedScrollRef}
|
// meanwhile keeps the container's height real so the scroller
|
||||||
startMargin={archivedScrollMargin}
|
// never collapses/clamps during the flip.
|
||||||
>
|
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
||||||
{(node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
|
||||||
subtreeContainsActive,
|
subtreeContainsActive,
|
||||||
subtreeContainsEditing,
|
subtreeContainsEditing,
|
||||||
menuOpenSessionId,
|
menuOpenSessionId,
|
||||||
nodeStructureKey: resolveNodeStructureKey(node),
|
nodeStructureKey: resolveNodeStructureKey(node),
|
||||||
childRenderExtrasFor,
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
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);
|
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>) => {
|
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
if (event.button === 2 || (event.button === 0 && event.ctrlKey && !selectionModeEnabled)) {
|
if (event.button === 2 || (event.button === 0 && event.ctrlKey && !selectionModeEnabled)) {
|
||||||
suppressNextSelectRef.current = true;
|
suppressNextSelectRef.current = true;
|
||||||
@@ -974,8 +986,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
data-session-row={session.id}
|
data-session-row={session.id}
|
||||||
data-session-scope={sessionDirectory ?? ''}
|
data-session-scope={sessionDirectory ?? ''}
|
||||||
data-session-archived={archivedBucket ? '1' : '0'}
|
data-session-archived={archivedBucket ? '1' : '0'}
|
||||||
|
onClick={handleRowBackgroundClick}
|
||||||
className={cn(
|
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
|
// Pull the row box left into the container gutter so the
|
||||||
// selection highlight covers the chevron/status markers
|
// selection highlight covers the chevron/status markers
|
||||||
// (which sit in that gutter), then re-pad so the title text
|
// (which sit in that gutter), then re-pad so the title text
|
||||||
|
|||||||
@@ -142,7 +142,12 @@ export function SidebarProjectsList(props: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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.topContent}
|
||||||
{props.showOnlyMainWorkspace ? (
|
{props.showOnlyMainWorkspace ? (
|
||||||
<div className="space-y-[0.6rem] py-1">
|
<div className="space-y-[0.6rem] py-1">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Virtualizer } from 'virtua';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
parseJsonToTree,
|
parseJsonToTree,
|
||||||
@@ -203,6 +203,14 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: ()
|
|||||||
|
|
||||||
const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD;
|
const shouldVirtualize = flatNodes.length > VIRTUALIZE_THRESHOLD;
|
||||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
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) => {
|
const handleToggle = React.useCallback((id: string) => {
|
||||||
setCollapsedPaths((prev) => {
|
setCollapsedPaths((prev) => {
|
||||||
@@ -238,21 +246,26 @@ const JsonTreeViewer = React.forwardRef<{ expandAll: () => void; collapseAll: ()
|
|||||||
className={className}
|
className={className}
|
||||||
style={{ maxHeight, overflow: 'auto' }}
|
style={{ maxHeight, overflow: 'auto' }}
|
||||||
>
|
>
|
||||||
<Virtualizer
|
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||||
data={flatNodes}
|
{virtualizer.getVirtualItems().map((item) => {
|
||||||
itemSize={ROW_HEIGHT}
|
const flatNode = flatNodes[item.index];
|
||||||
bufferSize={ROW_HEIGHT * 20}
|
if (!flatNode) return null;
|
||||||
scrollRef={parentRef}
|
return (
|
||||||
>
|
<div
|
||||||
{(flatNode) => (
|
key={flatNode.node.id}
|
||||||
<JsonRow
|
data-index={item.index}
|
||||||
key={flatNode.node.id}
|
ref={virtualizer.measureElement}
|
||||||
flatNode={flatNode}
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${item.start}px)` }}
|
||||||
onToggle={handleToggle}
|
>
|
||||||
onCopyPath={onCopyPath}
|
<JsonRow
|
||||||
/>
|
flatNode={flatNode}
|
||||||
)}
|
onToggle={handleToggle}
|
||||||
</Virtualizer>
|
onCopyPath={onCopyPath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Virtualizer, type VirtualizerHandle } from 'virtua';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -195,16 +195,26 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
|
|||||||
|
|
||||||
const rowCount = rows.length;
|
const rowCount = rows.length;
|
||||||
const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
const shouldVirtualize = rowCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||||
const rowVirtualizerRef = React.useRef<VirtualizerHandle | null>(null);
|
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||||
const [visibleStartIndex, setVisibleStartIndex] = React.useState(0);
|
count: rowCount,
|
||||||
|
enabled: shouldVirtualize,
|
||||||
const updateVisibleStartIndex = React.useCallback((offset: number) => {
|
getScrollElement: () => scrollRef.current,
|
||||||
const virtualizer = rowVirtualizerRef.current;
|
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
|
||||||
const next = virtualizer
|
overscan: 12,
|
||||||
? virtualizer.findItemIndex(offset)
|
getItemKey: (index) => rows[index]?.key ?? index,
|
||||||
: Math.floor(offset / CHANGE_ROW_ESTIMATE_PX);
|
});
|
||||||
setVisibleStartIndex((previous) => (previous === next ? previous : next));
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!onVisiblePathsChange) {
|
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"
|
className="overlay-scrollbar-target overlay-scrollbar-container min-h-0 w-full flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
>
|
>
|
||||||
{shouldVirtualize ? (
|
{shouldVirtualize ? (
|
||||||
<Virtualizer
|
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||||
ref={rowVirtualizerRef}
|
{/* Absolutely positioned rows: variable-height rows can drift from
|
||||||
data={rows}
|
the computed total height under flow stacking until measured. */}
|
||||||
itemSize={CHANGE_ROW_ESTIMATE_PX}
|
{virtualRows.map((item) => {
|
||||||
bufferSize={CHANGE_ROW_ESTIMATE_PX * 12}
|
const row = rows[item.index];
|
||||||
scrollRef={scrollRef}
|
if (!row) return null;
|
||||||
onScroll={updateVisibleStartIndex}
|
return (
|
||||||
>
|
<div
|
||||||
{(row, index) => (
|
key={row.key}
|
||||||
<div
|
data-index={item.index}
|
||||||
key={row.key}
|
ref={rowVirtualizer.measureElement}
|
||||||
className={cn(
|
style={{
|
||||||
'relative',
|
position: 'absolute',
|
||||||
showDivider(index) &&
|
top: 0,
|
||||||
'before:pointer-events-none before:absolute before:left-0 before:right-2 before:top-0 before:border-t before:border-border/60'
|
left: 0,
|
||||||
)}
|
width: '100%',
|
||||||
>
|
transform: `translateY(${item.start}px)`,
|
||||||
{renderRow(row, index === 0)}
|
}}
|
||||||
</div>
|
className={cn(
|
||||||
)}
|
showDivider(item.index) &&
|
||||||
</Virtualizer>
|
'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')}>
|
<div role="list" aria-label={t('gitView.changes.changedFilesAria')}>
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user