mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-06 07:01:28 -07:00
44259a00e2
Checkpoint optimizing virtualized list Fixes for fallback rendering where terminalBuffer=false Change terminalBuffer false back to the default while we fix performance with very large chats. Checkpoint changes to virtualized list. Fix virtualized list NO commit Update ink version. Fix UI snapshot mismatch in MainContent tests and VirtualizedList computation Checkpoint. Optimize scrolling checkpoint Fix tests and resolve remaining issues after rebase - Fixed ToolStickyHeaderRegression scrolling logic. - Added MouseProvider to VirtualizedList test. - Updated snapshots to reflect layout changes in optimized virtual list. Fix flaky plan-mode test by removing model request assertions Fix unit tests by updating expected decisions for tool permissions after rebase use onStaticRender in VirtualizedList Checkpoint VirtualizedListClick Update version Bug fixes. Code review comment fixes. settings.json hack Update version and local tweaks.
317 lines
9.1 KiB
TypeScript
317 lines
9.1 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
useRef,
|
|
forwardRef,
|
|
useImperativeHandle,
|
|
useCallback,
|
|
useMemo,
|
|
useLayoutEffect,
|
|
useEffect,
|
|
useContext,
|
|
} from 'react';
|
|
import type React from 'react';
|
|
import {
|
|
VirtualizedList,
|
|
type VirtualizedListRef,
|
|
type VirtualizedListProps,
|
|
SCROLL_TO_ITEM_END,
|
|
} from './VirtualizedList.js';
|
|
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
|
import { Box, type DOMElement } from 'ink';
|
|
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
|
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;
|
|
stableScrollback?: boolean;
|
|
isStatic?: boolean;
|
|
targetScrollIndex?: number;
|
|
containerHeight?: number;
|
|
scrollbarThumbColor?: string;
|
|
}
|
|
|
|
export type ScrollableListRef<T> = VirtualizedListRef<T>;
|
|
|
|
function ScrollableList<T>(
|
|
props: ScrollableListProps<T>,
|
|
ref: React.Ref<ScrollableListRef<T>>,
|
|
) {
|
|
const keyMatchers = useKeyMatchers();
|
|
const settings = useSettings();
|
|
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
|
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,
|
|
() => ({
|
|
scrollBy: (delta) => virtualizedListRef.current?.scrollBy(delta),
|
|
scrollTo: (offset) => virtualizedListRef.current?.scrollTo(offset),
|
|
scrollToEnd: () => virtualizedListRef.current?.scrollToEnd(),
|
|
scrollToIndex: (params) =>
|
|
virtualizedListRef.current?.scrollToIndex(params),
|
|
scrollToItem: (params) =>
|
|
virtualizedListRef.current?.scrollToItem(params),
|
|
getScrollIndex: () => virtualizedListRef.current?.getScrollIndex() ?? 0,
|
|
getScrollState: () =>
|
|
virtualizedListRef.current?.getScrollState() ?? {
|
|
scrollTop: 0,
|
|
scrollHeight: 0,
|
|
innerHeight: 0,
|
|
},
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const getScrollState = useCallback(
|
|
() =>
|
|
virtualizedListRef.current?.getScrollState() ?? {
|
|
scrollTop: 0,
|
|
scrollHeight: 0,
|
|
innerHeight: 0,
|
|
},
|
|
[],
|
|
);
|
|
|
|
const scrollBy = useCallback((delta: number) => {
|
|
virtualizedListRef.current?.scrollBy(delta);
|
|
}, []);
|
|
|
|
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
|
useAnimatedScrollbar(hasFocus, scrollBy);
|
|
|
|
const smoothScrollState = useRef<{
|
|
active: boolean;
|
|
start: number;
|
|
from: number;
|
|
to: number;
|
|
duration: number;
|
|
timer: NodeJS.Timeout | null;
|
|
}>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null });
|
|
|
|
const stopSmoothScroll = useCallback(() => {
|
|
if (smoothScrollState.current.timer) {
|
|
clearInterval(smoothScrollState.current.timer);
|
|
smoothScrollState.current.timer = null;
|
|
}
|
|
smoothScrollState.current.active = false;
|
|
}, []);
|
|
|
|
useLayoutEffect(() => stopSmoothScroll, [stopSmoothScroll]);
|
|
|
|
const smoothScrollTo = useCallback(
|
|
(
|
|
targetScrollTop: number,
|
|
duration: number = process.env['NODE_ENV'] === 'test' ? 0 : 200,
|
|
) => {
|
|
stopSmoothScroll();
|
|
|
|
const scrollState = virtualizedListRef.current?.getScrollState() ?? {
|
|
scrollTop: 0,
|
|
scrollHeight: 0,
|
|
innerHeight: 0,
|
|
};
|
|
const {
|
|
scrollTop: rawStartScrollTop,
|
|
scrollHeight,
|
|
innerHeight,
|
|
} = scrollState;
|
|
|
|
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
|
|
const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop);
|
|
|
|
let effectiveTarget = targetScrollTop;
|
|
if (
|
|
targetScrollTop === SCROLL_TO_ITEM_END ||
|
|
targetScrollTop >= maxScrollTop
|
|
) {
|
|
effectiveTarget = maxScrollTop;
|
|
}
|
|
|
|
const clampedTarget = Math.max(
|
|
0,
|
|
Math.min(maxScrollTop, effectiveTarget),
|
|
);
|
|
|
|
if (duration === 0) {
|
|
if (
|
|
targetScrollTop === SCROLL_TO_ITEM_END ||
|
|
targetScrollTop >= maxScrollTop
|
|
) {
|
|
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
|
|
} else {
|
|
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
|
|
}
|
|
flashScrollbar();
|
|
return;
|
|
}
|
|
|
|
smoothScrollState.current = {
|
|
active: true,
|
|
start: Date.now(),
|
|
from: startScrollTop,
|
|
to: clampedTarget,
|
|
duration,
|
|
timer: setInterval(() => {
|
|
const now = Date.now();
|
|
const elapsed = now - smoothScrollState.current.start;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// Ease-in-out
|
|
const t = progress;
|
|
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
|
|
const current =
|
|
smoothScrollState.current.from +
|
|
(smoothScrollState.current.to - smoothScrollState.current.from) *
|
|
ease;
|
|
|
|
if (progress >= 1) {
|
|
if (
|
|
targetScrollTop === SCROLL_TO_ITEM_END ||
|
|
targetScrollTop >= maxScrollTop
|
|
) {
|
|
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
|
|
} else {
|
|
virtualizedListRef.current?.scrollTo(Math.round(current));
|
|
}
|
|
stopSmoothScroll();
|
|
flashScrollbar();
|
|
} else {
|
|
virtualizedListRef.current?.scrollTo(Math.round(current));
|
|
}
|
|
}, ANIMATION_FRAME_DURATION_MS),
|
|
};
|
|
},
|
|
[stopSmoothScroll, flashScrollbar],
|
|
);
|
|
|
|
useKeypress(
|
|
(key: Key) => {
|
|
if (keyMatchers[Command.SCROLL_UP](key)) {
|
|
stopSmoothScroll();
|
|
scrollByWithAnimation(-1);
|
|
return true;
|
|
} else if (keyMatchers[Command.SCROLL_DOWN](key)) {
|
|
stopSmoothScroll();
|
|
scrollByWithAnimation(1);
|
|
return true;
|
|
} else if (
|
|
keyMatchers[Command.PAGE_UP](key) ||
|
|
keyMatchers[Command.PAGE_DOWN](key)
|
|
) {
|
|
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
|
|
const scrollState = getScrollState();
|
|
const maxScroll = Math.max(
|
|
0,
|
|
scrollState.scrollHeight - scrollState.innerHeight,
|
|
);
|
|
const current = smoothScrollState.current.active
|
|
? smoothScrollState.current.to
|
|
: Math.min(scrollState.scrollTop, maxScroll);
|
|
const innerHeight = scrollState.innerHeight;
|
|
smoothScrollTo(current + direction * innerHeight);
|
|
return true;
|
|
} else if (keyMatchers[Command.SCROLL_HOME](key)) {
|
|
smoothScrollTo(0);
|
|
return true;
|
|
} else if (keyMatchers[Command.SCROLL_END](key)) {
|
|
smoothScrollTo(SCROLL_TO_ITEM_END);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
{ isActive: hasFocus },
|
|
);
|
|
|
|
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
|
|
|
const scrollableEntry = useMemo(
|
|
() => ({
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
ref: containerRef as React.RefObject<DOMElement>,
|
|
getScrollState,
|
|
scrollBy: scrollByWithAnimation,
|
|
scrollTo: smoothScrollTo,
|
|
hasFocus: hasFocusCallback,
|
|
flashScrollbar,
|
|
}),
|
|
[
|
|
getScrollState,
|
|
hasFocusCallback,
|
|
flashScrollbar,
|
|
scrollByWithAnimation,
|
|
smoothScrollTo,
|
|
],
|
|
);
|
|
|
|
useScrollable(scrollableEntry, true);
|
|
|
|
return (
|
|
<Box ref={containerRef} flexGrow={1} flexDirection="column" width={width}>
|
|
<VirtualizedList
|
|
ref={virtualizedListRef}
|
|
{...props}
|
|
scrollbar={scrollbar}
|
|
scrollbarThumbColor={scrollbarColor}
|
|
stableScrollback={stableScrollback}
|
|
maxScrollbackLength={maxScrollbackLength}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const ScrollableListWithForwardRef = forwardRef(ScrollableList) as <T>(
|
|
props: ScrollableListProps<T> & { ref?: React.Ref<ScrollableListRef<T>> },
|
|
) => React.ReactElement;
|
|
|
|
export { ScrollableListWithForwardRef as ScrollableList };
|