Files
gemini-cli/packages/cli/src/ui/components/shared/ScrollableList.tsx
T

317 lines
9.1 KiB
TypeScript
Raw Normal View History

2025-11-04 16:21:00 -08:00
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
useRef,
forwardRef,
useImperativeHandle,
useCallback,
useMemo,
useLayoutEffect,
2026-04-06 21:30:30 -07:00
useEffect,
useContext,
2025-11-04 16:21:00 -08:00
} from 'react';
import type React from 'react';
import {
VirtualizedList,
type VirtualizedListRef,
type VirtualizedListProps,
SCROLL_TO_ITEM_END,
} from './VirtualizedList.js';
2025-11-04 16:21:00 -08:00
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';
2026-04-06 21:30:30 -07:00
import { useSettings } from '../../contexts/SettingsContext.js';
import { VirtualizedListContext } from './VirtualizedList.js';
const ANIMATION_FRAME_DURATION_MS = 33;
2025-11-04 16:21:00 -08:00
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
2026-04-06 21:30:30 -07:00
itemKey?: string;
2025-11-04 16:21:00 -08:00
hasFocus: boolean;
width?: string | number;
scrollbar?: boolean;
stableScrollback?: boolean;
isStatic?: boolean;
targetScrollIndex?: number;
containerHeight?: number;
scrollbarThumbColor?: string;
2025-11-04 16:21:00 -08:00
}
export type ScrollableListRef<T> = VirtualizedListRef<T>;
function ScrollableList<T>(
props: ScrollableListProps<T>,
ref: React.Ref<ScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
2026-04-06 21:30:30 -07:00
const settings = useSettings();
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
const {
hasFocus,
width,
scrollbar = true,
stableScrollback,
itemKey,
} = props;
2025-11-04 16:21:00 -08:00
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
2026-04-06 21:30:30 -07:00
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],
);
2025-11-04 16:21:00 -08:00
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),
);
2025-11-13 14:13:18 -08:00
if (duration === 0) {
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
2025-11-13 14:13:18 -08:00
} 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],
);
2025-11-04 16:21:00 -08:00
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;
2025-11-04 16:21:00 -08:00
}
return false;
2025-11-04 16:21:00 -08:00
},
{ isActive: hasFocus },
);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
const scrollableEntry = useMemo(
() => ({
2026-02-10 00:10:15 +00:00
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
2025-11-04 16:21:00 -08:00
ref: containerRef as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
2025-11-13 14:13:18 -08:00
scrollTo: smoothScrollTo,
2025-11-04 16:21:00 -08:00
hasFocus: hasFocusCallback,
flashScrollbar,
}),
2025-11-13 14:13:18 -08:00
[
getScrollState,
hasFocusCallback,
flashScrollbar,
scrollByWithAnimation,
smoothScrollTo,
],
2025-11-04 16:21:00 -08:00
);
useScrollable(scrollableEntry, true);
2025-11-04 16:21:00 -08:00
return (
<Box ref={containerRef} flexGrow={1} flexDirection="column" width={width}>
2025-11-04 16:21:00 -08:00
<VirtualizedList
ref={virtualizedListRef}
{...props}
scrollbar={scrollbar}
2025-11-04 16:21:00 -08:00
scrollbarThumbColor={scrollbarColor}
stableScrollback={stableScrollback}
2026-04-06 21:30:30 -07:00
maxScrollbackLength={maxScrollbackLength}
2025-11-04 16:21:00 -08:00
/>
</Box>
);
}
2026-02-10 00:10:15 +00:00
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
2025-11-04 16:21:00 -08:00
const ScrollableListWithForwardRef = forwardRef(ScrollableList) as <T>(
props: ScrollableListProps<T> & { ref?: React.Ref<ScrollableListRef<T>> },
) => React.ReactElement;
export { ScrollableListWithForwardRef as ScrollableList };