mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-14 08:01:02 -07:00
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
192 lines
5.2 KiB
TypeScript
192 lines
5.2 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import React, {
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
useLayoutEffect,
|
|
useCallback,
|
|
useMemo,
|
|
} from 'react';
|
|
import { Box, getInnerHeight, getScrollHeight, 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 { keyMatchers, Command } from '../../keyMatchers.js';
|
|
|
|
interface ScrollableProps {
|
|
children?: React.ReactNode;
|
|
width?: number;
|
|
height?: number | string;
|
|
maxWidth?: number;
|
|
maxHeight?: number;
|
|
hasFocus: boolean;
|
|
scrollToBottom?: boolean;
|
|
flexGrow?: number;
|
|
}
|
|
|
|
export const Scrollable: React.FC<ScrollableProps> = ({
|
|
children,
|
|
width,
|
|
height,
|
|
maxWidth,
|
|
maxHeight,
|
|
hasFocus,
|
|
scrollToBottom,
|
|
flexGrow,
|
|
}) => {
|
|
const [scrollTop, setScrollTop] = useState(0);
|
|
const ref = useRef<DOMElement>(null);
|
|
const [size, setSize] = useState({
|
|
innerHeight: 0,
|
|
scrollHeight: 0,
|
|
});
|
|
const sizeRef = useRef(size);
|
|
useEffect(() => {
|
|
sizeRef.current = size;
|
|
}, [size]);
|
|
|
|
const childrenCountRef = useRef(0);
|
|
|
|
// This effect needs to run on every render to correctly measure the container
|
|
// and scroll to the bottom if new children are added.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
useLayoutEffect(() => {
|
|
if (!ref.current) {
|
|
return;
|
|
}
|
|
const innerHeight = Math.round(getInnerHeight(ref.current));
|
|
const scrollHeight = Math.round(getScrollHeight(ref.current));
|
|
|
|
const isAtBottom =
|
|
scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1;
|
|
|
|
if (
|
|
size.innerHeight !== innerHeight ||
|
|
size.scrollHeight !== scrollHeight
|
|
) {
|
|
setSize({ innerHeight, scrollHeight });
|
|
if (isAtBottom) {
|
|
setScrollTop(Math.max(0, scrollHeight - innerHeight));
|
|
}
|
|
}
|
|
|
|
const childCountCurrent = React.Children.count(children);
|
|
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
|
|
setScrollTop(Math.max(0, scrollHeight - innerHeight));
|
|
}
|
|
childrenCountRef.current = childCountCurrent;
|
|
});
|
|
|
|
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
|
|
|
const scrollBy = useCallback(
|
|
(delta: number) => {
|
|
const { scrollHeight, innerHeight } = sizeRef.current;
|
|
const current = getScrollTop();
|
|
const next = Math.min(
|
|
Math.max(0, current + delta),
|
|
Math.max(0, scrollHeight - innerHeight),
|
|
);
|
|
setPendingScrollTop(next);
|
|
setScrollTop(next);
|
|
},
|
|
[sizeRef, 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);
|
|
|
|
// Only capture scroll-up events if there's room;
|
|
// otherwise allow events to bubble.
|
|
if (scrollTop > 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 (scrollTop < 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(
|
|
() => ({
|
|
scrollTop: getScrollTop(),
|
|
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: ref as React.RefObject<DOMElement>,
|
|
getScrollState,
|
|
scrollBy: scrollByWithAnimation,
|
|
hasFocus: hasFocusCallback,
|
|
flashScrollbar,
|
|
}),
|
|
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
|
|
);
|
|
|
|
useScrollable(scrollableEntry, true);
|
|
|
|
return (
|
|
<Box
|
|
ref={ref}
|
|
maxHeight={maxHeight}
|
|
width={width ?? maxWidth}
|
|
height={height}
|
|
flexDirection="column"
|
|
overflowY="scroll"
|
|
overflowX="hidden"
|
|
scrollTop={scrollTop}
|
|
flexGrow={flexGrow}
|
|
scrollbarThumbColor={scrollbarColor}
|
|
>
|
|
{/*
|
|
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 flexShrink={0} paddingRight={1} flexDirection="column">
|
|
{children}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|