Files
gemini-cli/packages/cli/src/ui/components/shared/ScrollableList.tsx
2025-11-05 00:21:00 +00:00

132 lines
3.5 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useRef,
forwardRef,
useImperativeHandle,
useCallback,
useMemo,
} from 'react';
import type React from 'react';
import { VirtualizedList, type VirtualizedListRef } 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';
type VirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
estimatedItemHeight: (index: number) => number;
keyExtractor: (item: T, index: number) => string;
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
};
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
hasFocus: boolean;
}
export type ScrollableListRef<T> = VirtualizedListRef<T>;
function ScrollableList<T>(
props: ScrollableListProps<T>,
ref: React.Ref<ScrollableListRef<T>>,
) {
const { hasFocus } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
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);
useKeypress(
(key: Key) => {
if (key.shift) {
if (key.name === 'up') {
scrollByWithAnimation(-1);
}
if (key.name === 'down') {
scrollByWithAnimation(1);
}
}
},
{ isActive: hasFocus },
);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
const scrollableEntry = useMemo(
() => ({
ref: containerRef as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
hasFocus: hasFocusCallback,
flashScrollbar,
}),
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
);
useScrollable(scrollableEntry, hasFocus);
return (
<Box
ref={containerRef}
flexGrow={1}
flexDirection="column"
overflow="hidden"
>
<VirtualizedList
ref={virtualizedListRef}
{...props}
scrollbarThumbColor={scrollbarColor}
/>
</Box>
);
}
const ScrollableListWithForwardRef = forwardRef(ScrollableList) as <T>(
props: ScrollableListProps<T> & { ref?: React.Ref<ScrollableListRef<T>> },
) => React.ReactElement;
export { ScrollableListWithForwardRef as ScrollableList };