mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-11 10:30:46 -07:00
Optimize scrolling checkpoint
This commit is contained in:
@@ -11,6 +11,8 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
@@ -26,10 +28,12 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
interface FixedScrollableListProps<T> extends FixedVirtualizedListProps<T> {
|
||||
itemKey?: string;
|
||||
hasFocus: boolean;
|
||||
width: number;
|
||||
scrollbar?: boolean;
|
||||
@@ -50,6 +54,7 @@ function FixedScrollableList<T>(
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const {
|
||||
itemKey,
|
||||
hasFocus,
|
||||
width,
|
||||
maxHeight,
|
||||
@@ -59,6 +64,32 @@ function FixedScrollableList<T>(
|
||||
const fixedVirtualizedListRef = useRef<FixedVirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const restoredTop = virtualizedListContext.getItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
);
|
||||
if (typeof restoredTop === 'number') {
|
||||
fixedVirtualizedListRef.current?.scrollTo(restoredTop);
|
||||
}
|
||||
}
|
||||
}, [itemKey, virtualizedListContext]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const top = fixedVirtualizedListRef.current?.getScrollState().scrollTop;
|
||||
if (top !== undefined) {
|
||||
virtualizedListContext.setItemState(itemKey, 'scrollTop', top);
|
||||
}
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useId,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import { Box, ResizeObserver, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
@@ -22,9 +23,11 @@ import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
interface ScrollableProps {
|
||||
children?: React.ReactNode;
|
||||
itemKey?: string;
|
||||
width?: number;
|
||||
height?: number | string;
|
||||
maxWidth?: number;
|
||||
@@ -40,6 +43,7 @@ interface ScrollableProps {
|
||||
|
||||
export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
children,
|
||||
itemKey,
|
||||
width,
|
||||
height,
|
||||
maxWidth,
|
||||
@@ -53,7 +57,16 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
stableScrollback,
|
||||
}) => {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
const [scrollTop, setScrollTop] = useState(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const state = virtualizedListContext.getItemState(itemKey, 'scrollTop');
|
||||
return typeof state === 'number' ? state : 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const viewportRef = useRef<DOMElement | null>(null);
|
||||
const contentRef = useRef<DOMElement | null>(null);
|
||||
const overflowActions = useOverflowActions();
|
||||
@@ -73,6 +86,19 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
virtualizedListContext.setItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
scrollTopRef.current,
|
||||
);
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (reportOverflow && size.scrollHeight > size.innerHeight) {
|
||||
overflowActions?.addOverflowingId?.(id);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
useEffect,
|
||||
useContext,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
@@ -26,10 +28,12 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { VirtualizedListContext } from './VirtualizedList.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
itemKey?: string;
|
||||
hasFocus: boolean;
|
||||
width?: string | number;
|
||||
scrollbar?: boolean;
|
||||
@@ -49,10 +53,42 @@ function ScrollableList<T>(
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
|
||||
const {
|
||||
hasFocus,
|
||||
width,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
itemKey,
|
||||
} = props;
|
||||
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const restoredTop = virtualizedListContext.getItemState(
|
||||
itemKey,
|
||||
'scrollTop',
|
||||
);
|
||||
if (typeof restoredTop === 'number') {
|
||||
virtualizedListRef.current?.scrollTo(restoredTop);
|
||||
}
|
||||
}
|
||||
}, [itemKey, virtualizedListContext]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (itemKey && virtualizedListContext) {
|
||||
const top = virtualizedListRef.current?.getScrollState().scrollTop;
|
||||
if (top !== undefined) {
|
||||
virtualizedListContext.setItemState(itemKey, 'scrollTop', top);
|
||||
}
|
||||
}
|
||||
},
|
||||
[itemKey, virtualizedListContext],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
|
||||
@@ -14,15 +14,44 @@ import {
|
||||
useCallback,
|
||||
memo,
|
||||
useEffect,
|
||||
createContext,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
|
||||
import { type DOMElement, Box, ResizeObserver, StaticRender } from 'ink';
|
||||
import {
|
||||
type DOMElement,
|
||||
Box,
|
||||
ResizeObserver,
|
||||
StaticRender,
|
||||
getBoundingBox,
|
||||
getScrollTop as getInkScrollTop,
|
||||
} from 'ink';
|
||||
import {
|
||||
useMouse,
|
||||
useMouseContext,
|
||||
type MouseEvent,
|
||||
} from '../../contexts/MouseContext.js';
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export interface VirtualizedListContextValue {
|
||||
registerInteractivity: (
|
||||
itemKey: string,
|
||||
options: { scroll?: boolean; click?: boolean },
|
||||
) => void;
|
||||
setItemState: (itemKey: string, stateKey: string, value: unknown) => void;
|
||||
getItemState: (itemKey: string, stateKey: string) => unknown;
|
||||
isItemToggled: (itemKey: string) => boolean;
|
||||
toggleItem: (itemKey: string) => void;
|
||||
}
|
||||
|
||||
export const VirtualizedListContext =
|
||||
createContext<VirtualizedListContextValue | null>(null);
|
||||
|
||||
export type VirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
@@ -213,6 +242,51 @@ function VirtualizedList<T>(
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [measurementVersion, setMeasurementVersion] = useState(0);
|
||||
|
||||
const interactiveKeys = useRef(
|
||||
new Map<string, { scroll?: boolean; click?: boolean }>(),
|
||||
);
|
||||
const itemStates = useRef(new Map<string, Map<string, unknown>>());
|
||||
const [toggledKeys, setToggledKeys] = useState(() => new Set<string>());
|
||||
const [temporarilyInteractiveIndexes, setTemporarilyInteractiveIndexes] =
|
||||
useState(() => new Set<number>());
|
||||
const renderedAsStatic = useRef<boolean[]>([]);
|
||||
const maxRenderRangeEnd = useRef(0);
|
||||
const [pendingReplayEvent, setPendingReplayEvent] = useState<{
|
||||
index: number;
|
||||
event: MouseEvent;
|
||||
} | null>(null);
|
||||
|
||||
const virtualizedListContextValue = useMemo<VirtualizedListContextValue>(
|
||||
() => ({
|
||||
registerInteractivity: (itemKey, options) => {
|
||||
interactiveKeys.current.set(itemKey, options);
|
||||
},
|
||||
setItemState: (itemKey, stateKey, value) => {
|
||||
let stateMap = itemStates.current.get(itemKey);
|
||||
if (!stateMap) {
|
||||
stateMap = new Map();
|
||||
itemStates.current.set(itemKey, stateMap);
|
||||
}
|
||||
stateMap.set(stateKey, value);
|
||||
},
|
||||
getItemState: (itemKey, stateKey) =>
|
||||
itemStates.current.get(itemKey)?.get(stateKey),
|
||||
isItemToggled: (itemKey) => toggledKeys.has(itemKey),
|
||||
toggleItem: (itemKey) => {
|
||||
setToggledKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(itemKey)) {
|
||||
next.delete(itemKey);
|
||||
} else {
|
||||
next.add(itemKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
}),
|
||||
[toggledKeys],
|
||||
);
|
||||
|
||||
const state = useRef<VirtualizedListInternalState>({
|
||||
container: null,
|
||||
itemRefs: [],
|
||||
@@ -602,6 +676,21 @@ function VirtualizedList<T>(
|
||||
? data.length - 1
|
||||
: Math.min(data.length - 1, endIndexOffset);
|
||||
|
||||
useEffect(() => {
|
||||
setTemporarilyInteractiveIndexes((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
let changed = false;
|
||||
const next = new Set(prev);
|
||||
for (const index of prev) {
|
||||
if (index > endIndex) {
|
||||
next.delete(index);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [endIndex]);
|
||||
|
||||
const renderRangeStart = useMemo(() => {
|
||||
if (overflowToBackbuffer) {
|
||||
if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) {
|
||||
@@ -624,10 +713,32 @@ function VirtualizedList<T>(
|
||||
]);
|
||||
|
||||
const topSpacerHeight = offsets[renderRangeStart];
|
||||
const bottomSpacerHeight =
|
||||
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
||||
|
||||
const renderRangeEnd = endIndex;
|
||||
let renderRangeEnd = endIndex;
|
||||
if (maxRenderRangeEnd.current > endIndex) {
|
||||
let allStatic = true;
|
||||
const currentMax = Math.min(
|
||||
maxRenderRangeEnd.current,
|
||||
data.length > 0 ? data.length - 1 : 0,
|
||||
);
|
||||
for (let i = endIndex + 1; i <= currentMax; i++) {
|
||||
const item = data[i];
|
||||
if (!item) continue;
|
||||
const isStaticByDefault =
|
||||
renderStatic === true || isStaticItem?.(item, i) === true;
|
||||
if (!isStaticByDefault) {
|
||||
allStatic = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allStatic) {
|
||||
renderRangeEnd = currentMax;
|
||||
}
|
||||
}
|
||||
maxRenderRangeEnd.current = renderRangeEnd;
|
||||
|
||||
const bottomSpacerHeight =
|
||||
totalHeight - (offsets[renderRangeEnd + 1] ?? totalHeight);
|
||||
|
||||
// Always evaluate shouldBeStatic, width, etc. if we have a known width from the prop.
|
||||
// If containerHeight or containerWidth is 0 we defer rendering unless a static render or defined width overrides.
|
||||
@@ -650,10 +761,15 @@ function VirtualizedList<T>(
|
||||
const item = data[i];
|
||||
if (item) {
|
||||
const isOutsideViewport = i < startIndex || i > endIndex;
|
||||
const shouldBeStatic =
|
||||
const isStaticByDefault =
|
||||
(renderStatic === true && isOutsideViewport) ||
|
||||
isStaticItem?.(item, i) === true;
|
||||
|
||||
const isTemporarilyInteractive =
|
||||
temporarilyInteractiveIndexes.has(i) && i <= endIndex;
|
||||
const shouldBeStatic = isStaticByDefault && !isTemporarilyInteractive;
|
||||
renderedAsStatic.current[i] = shouldBeStatic;
|
||||
|
||||
const content = renderItem({ item, index: i });
|
||||
const key = keyExtractor(item, i);
|
||||
|
||||
@@ -712,10 +828,103 @@ function VirtualizedList<T>(
|
||||
containerWidth,
|
||||
onSetRef,
|
||||
estimatedItemHeight,
|
||||
temporarilyInteractiveIndexes,
|
||||
]);
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
|
||||
const { broadcast } = useMouseContext();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (pendingReplayEvent) {
|
||||
const { index, event } = pendingReplayEvent;
|
||||
// Replay if the item has been mounted
|
||||
if (state.current.itemRefs[index]) {
|
||||
setTimeout(() => {
|
||||
debugLogger.log(`[Mouse] Replaying event index=${index}`);
|
||||
broadcast(event);
|
||||
}, 150); // Allow Ink's Yoga engine time to calculate bounding boxes
|
||||
setPendingReplayEvent(null);
|
||||
}
|
||||
}
|
||||
}, [pendingReplayEvent, broadcast]);
|
||||
|
||||
const handleMouse = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!state.current.container) return;
|
||||
|
||||
const isClick = event.name === 'left-press';
|
||||
const isScroll =
|
||||
event.name === 'scroll-up' || event.name === 'scroll-down';
|
||||
if (!isClick && !isScroll) return;
|
||||
|
||||
const { x, y, width, height } = getBoundingBox(state.current.container);
|
||||
const mouseX = event.col - 1;
|
||||
const mouseY = event.row - 1;
|
||||
|
||||
const relativeX = mouseX - x;
|
||||
const relativeY = mouseY - y;
|
||||
|
||||
if (
|
||||
relativeX >= 0 &&
|
||||
relativeX < width &&
|
||||
relativeY >= 0 &&
|
||||
relativeY < height
|
||||
) {
|
||||
// getScrollTop() might return MAX_SAFE_INTEGER if stuck to bottom.
|
||||
// We need the true rendered layout scroll top which ink exposes directly via getScrollTop.
|
||||
const trueScrollTop = getInkScrollTop(state.current.container);
|
||||
const absoluteY = trueScrollTop + relativeY;
|
||||
|
||||
const index = findLastIndex(offsets, (offset) => offset <= absoluteY);
|
||||
|
||||
// DEBUG LOGGING
|
||||
debugLogger.log(
|
||||
`[Mouse] event=${event.name} index=${index} static=${renderedAsStatic.current[index]}`,
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
const item = data[index];
|
||||
if (item) {
|
||||
const itemKey = keyExtractor(item, index);
|
||||
const options = interactiveKeys.current.get(itemKey);
|
||||
|
||||
debugLogger.log(
|
||||
`[Mouse] itemKey=${itemKey} options=${JSON.stringify(options)}`,
|
||||
);
|
||||
if (options) {
|
||||
// Determine if the click was exactly on the first line of the item
|
||||
const itemStartY = offsets[index];
|
||||
const isFirstLineClick = isClick && absoluteY === itemStartY;
|
||||
|
||||
if (isFirstLineClick && options.click) {
|
||||
debugLogger.log(
|
||||
`[Mouse] First line click detected. Toggling itemKey=${itemKey}.`,
|
||||
);
|
||||
virtualizedListContextValue.toggleItem(itemKey);
|
||||
} else if (
|
||||
renderedAsStatic.current[index] &&
|
||||
isScroll &&
|
||||
options.scroll
|
||||
) {
|
||||
// Only wake up the item for scroll events
|
||||
setTemporarilyInteractiveIndexes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(index);
|
||||
return next;
|
||||
});
|
||||
setPendingReplayEvent({ index, event });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[offsets, data, keyExtractor, virtualizedListContextValue],
|
||||
);
|
||||
|
||||
useMouse(handleMouse, { isActive: true });
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -870,31 +1079,33 @@ function VirtualizedList<T>(
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRefCallback}
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={1}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
{topSpacerHeight > 0 ? (
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
) : null}
|
||||
{renderedItems}
|
||||
{bottomSpacerHeight > 0 ? (
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
) : null}
|
||||
<VirtualizedListContext.Provider value={virtualizedListContextValue}>
|
||||
<Box
|
||||
ref={containerRefCallback}
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={1}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
{topSpacerHeight > 0 ? (
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
) : null}
|
||||
{renderedItems}
|
||||
{bottomSpacerHeight > 0 ? (
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user