mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-04 06:01:15 -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.
310 lines
8.5 KiB
TypeScript
310 lines
8.5 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type React from 'react';
|
|
import {
|
|
useState,
|
|
useRef,
|
|
useCallback,
|
|
useMemo,
|
|
useLayoutEffect,
|
|
useEffect,
|
|
useId,
|
|
useContext,
|
|
} from 'react';
|
|
import { Box, ResizeObserver, type DOMElement } from 'ink';
|
|
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
|
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
|
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
|
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;
|
|
maxHeight?: number;
|
|
hasFocus: boolean;
|
|
scrollToBottom?: boolean;
|
|
flexGrow?: number;
|
|
reportOverflow?: boolean;
|
|
overflowToBackbuffer?: boolean;
|
|
scrollbar?: boolean;
|
|
stableScrollback?: boolean;
|
|
}
|
|
|
|
export const Scrollable: React.FC<ScrollableProps> = ({
|
|
children,
|
|
itemKey,
|
|
width,
|
|
height,
|
|
maxWidth,
|
|
maxHeight,
|
|
hasFocus,
|
|
scrollToBottom,
|
|
flexGrow,
|
|
reportOverflow = false,
|
|
overflowToBackbuffer,
|
|
scrollbar = true,
|
|
stableScrollback,
|
|
}) => {
|
|
const keyMatchers = useKeyMatchers();
|
|
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();
|
|
const id = useId();
|
|
const [size, setSize] = useState({
|
|
innerHeight: typeof height === 'number' ? height : 0,
|
|
scrollHeight: 0,
|
|
});
|
|
const sizeRef = useRef(size);
|
|
const scrollTopRef = useRef(scrollTop);
|
|
|
|
useLayoutEffect(() => {
|
|
sizeRef.current = size;
|
|
}, [size]);
|
|
|
|
useLayoutEffect(() => {
|
|
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);
|
|
} else {
|
|
overflowActions?.removeOverflowingId?.(id);
|
|
}
|
|
}, [
|
|
reportOverflow,
|
|
size.scrollHeight,
|
|
size.innerHeight,
|
|
id,
|
|
overflowActions,
|
|
]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
overflowActions?.removeOverflowingId?.(id);
|
|
},
|
|
[id, overflowActions],
|
|
);
|
|
|
|
const viewportObserverRef = useRef<ResizeObserver | null>(null);
|
|
const contentObserverRef = useRef<ResizeObserver | null>(null);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
viewportObserverRef.current?.disconnect();
|
|
contentObserverRef.current?.disconnect();
|
|
},
|
|
[],
|
|
);
|
|
|
|
const viewportRefCallback = useCallback((node: DOMElement | null) => {
|
|
viewportObserverRef.current?.disconnect();
|
|
viewportRef.current = node;
|
|
|
|
if (node) {
|
|
const observer = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (entry) {
|
|
const innerHeight = Math.round(entry.contentRect.height);
|
|
setSize((prev) => {
|
|
const scrollHeight = prev.scrollHeight;
|
|
const isAtBottom =
|
|
scrollHeight > prev.innerHeight &&
|
|
scrollTopRef.current >= scrollHeight - prev.innerHeight - 1;
|
|
|
|
if (isAtBottom) {
|
|
setScrollTop(Number.MAX_SAFE_INTEGER);
|
|
}
|
|
return { ...prev, innerHeight };
|
|
});
|
|
}
|
|
});
|
|
observer.observe(node);
|
|
viewportObserverRef.current = observer;
|
|
}
|
|
}, []);
|
|
|
|
const contentRefCallback = useCallback(
|
|
(node: DOMElement | null) => {
|
|
contentObserverRef.current?.disconnect();
|
|
contentRef.current = node;
|
|
|
|
if (node) {
|
|
const observer = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (entry) {
|
|
const scrollHeight = Math.round(entry.contentRect.height);
|
|
setSize((prev) => {
|
|
const innerHeight = prev.innerHeight;
|
|
const isAtBottom =
|
|
prev.scrollHeight > innerHeight &&
|
|
scrollTopRef.current >= prev.scrollHeight - innerHeight - 1;
|
|
|
|
if (
|
|
isAtBottom ||
|
|
(scrollToBottom && scrollHeight > prev.scrollHeight)
|
|
) {
|
|
setScrollTop(Number.MAX_SAFE_INTEGER);
|
|
}
|
|
return { ...prev, scrollHeight };
|
|
});
|
|
}
|
|
});
|
|
observer.observe(node);
|
|
contentObserverRef.current = observer;
|
|
}
|
|
},
|
|
[scrollToBottom],
|
|
);
|
|
|
|
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
|
|
|
const scrollBy = useCallback(
|
|
(delta: number) => {
|
|
const { scrollHeight, innerHeight } = sizeRef.current;
|
|
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
|
const current = Math.min(getScrollTop(), maxScroll);
|
|
let next = Math.max(0, current + delta);
|
|
if (next >= maxScroll) {
|
|
next = Number.MAX_SAFE_INTEGER;
|
|
}
|
|
setPendingScrollTop(next);
|
|
setScrollTop(next);
|
|
},
|
|
[getScrollTop, setPendingScrollTop],
|
|
);
|
|
|
|
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
|
useAnimatedScrollbar(hasFocus, scrollBy);
|
|
|
|
useKeypress(
|
|
(key: Key) => {
|
|
const { scrollHeight, innerHeight } = sizeRef.current;
|
|
const scrollTop = getScrollTop();
|
|
const maxScroll = Math.max(0, scrollHeight - innerHeight);
|
|
const actualScrollTop = Math.min(scrollTop, maxScroll);
|
|
|
|
// Only capture scroll-up events if there's room;
|
|
// otherwise allow events to bubble.
|
|
if (actualScrollTop > 0) {
|
|
if (keyMatchers[Command.PAGE_UP](key)) {
|
|
scrollByWithAnimation(-innerHeight);
|
|
return true;
|
|
}
|
|
if (keyMatchers[Command.SCROLL_UP](key)) {
|
|
scrollByWithAnimation(-1);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Only capture scroll-down events if there's room;
|
|
// otherwise allow events to bubble.
|
|
if (actualScrollTop < maxScroll) {
|
|
if (keyMatchers[Command.PAGE_DOWN](key)) {
|
|
scrollByWithAnimation(innerHeight);
|
|
return true;
|
|
}
|
|
if (keyMatchers[Command.SCROLL_DOWN](key)) {
|
|
scrollByWithAnimation(1);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// bubble keypress
|
|
return false;
|
|
},
|
|
{ isActive: hasFocus },
|
|
);
|
|
|
|
const getScrollState = useCallback(() => {
|
|
const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight);
|
|
return {
|
|
scrollTop: Math.min(getScrollTop(), maxScroll),
|
|
scrollHeight: size.scrollHeight,
|
|
innerHeight: size.innerHeight,
|
|
};
|
|
}, [getScrollTop, size.scrollHeight, size.innerHeight]);
|
|
|
|
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
|
|
|
const scrollableEntry = useMemo(
|
|
() => ({
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
ref: viewportRef as React.RefObject<DOMElement>,
|
|
getScrollState,
|
|
scrollBy: scrollByWithAnimation,
|
|
hasFocus: hasFocusCallback,
|
|
flashScrollbar,
|
|
}),
|
|
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
|
|
);
|
|
|
|
useScrollable(scrollableEntry, true);
|
|
|
|
return (
|
|
<Box
|
|
ref={viewportRefCallback}
|
|
maxHeight={maxHeight}
|
|
width={width ?? maxWidth}
|
|
height={height}
|
|
flexDirection="column"
|
|
overflowY="scroll"
|
|
overflowX="hidden"
|
|
scrollTop={scrollTop}
|
|
flexGrow={flexGrow}
|
|
scrollbarThumbColor={scrollbarColor}
|
|
overflowToBackbuffer={overflowToBackbuffer}
|
|
scrollbar={scrollbar}
|
|
stableScrollback={stableScrollback}
|
|
>
|
|
{/*
|
|
This inner box is necessary to prevent the parent from shrinking
|
|
based on the children's content. It also adds a right padding to
|
|
make room for the scrollbar.
|
|
*/}
|
|
<Box
|
|
ref={contentRefCallback}
|
|
flexShrink={0}
|
|
paddingRight={1}
|
|
flexDirection="column"
|
|
>
|
|
{children}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|