mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-05 19:01:12 -07:00
fix(cli): ensure dialogs stay scrolled to bottom in alternate buffer mode (#20527)
This commit is contained in:
@@ -4,15 +4,9 @@
|
||||
* 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 type React from 'react';
|
||||
import { useState, useRef, useCallback, useMemo, useLayoutEffect } 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';
|
||||
@@ -41,62 +35,101 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
flexGrow,
|
||||
}) => {
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const ref = useRef<DOMElement>(null);
|
||||
const viewportRef = useRef<DOMElement | null>(null);
|
||||
const contentRef = useRef<DOMElement | null>(null);
|
||||
const [size, setSize] = useState({
|
||||
innerHeight: 0,
|
||||
innerHeight: typeof height === 'number' ? height : 0,
|
||||
scrollHeight: 0,
|
||||
});
|
||||
const sizeRef = useRef(size);
|
||||
useEffect(() => {
|
||||
const scrollTopRef = useRef(scrollTop);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
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;
|
||||
scrollTopRef.current = scrollTop;
|
||||
}, [scrollTop]);
|
||||
|
||||
const viewportObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const contentObserverRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
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 innerHeight = Math.round(getInnerHeight(ref.current));
|
||||
const scrollHeight = Math.round(getScrollHeight(ref.current));
|
||||
}, []);
|
||||
|
||||
const isAtBottom =
|
||||
scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1;
|
||||
const contentRefCallback = useCallback(
|
||||
(node: DOMElement | null) => {
|
||||
contentObserverRef.current?.disconnect();
|
||||
contentRef.current = node;
|
||||
|
||||
if (
|
||||
size.innerHeight !== innerHeight ||
|
||||
size.scrollHeight !== scrollHeight
|
||||
) {
|
||||
setSize({ innerHeight, scrollHeight });
|
||||
if (isAtBottom) {
|
||||
setScrollTop(Math.max(0, scrollHeight - innerHeight));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const childCountCurrent = React.Children.count(children);
|
||||
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
|
||||
setScrollTop(Math.max(0, scrollHeight - innerHeight));
|
||||
}
|
||||
childrenCountRef.current = childCountCurrent;
|
||||
});
|
||||
},
|
||||
[scrollToBottom],
|
||||
);
|
||||
|
||||
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),
|
||||
);
|
||||
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);
|
||||
},
|
||||
[sizeRef, getScrollTop, setPendingScrollTop],
|
||||
[getScrollTop, setPendingScrollTop],
|
||||
);
|
||||
|
||||
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
||||
@@ -107,10 +140,11 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
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 (scrollTop > 0) {
|
||||
if (actualScrollTop > 0) {
|
||||
if (keyMatchers[Command.PAGE_UP](key)) {
|
||||
scrollByWithAnimation(-innerHeight);
|
||||
return true;
|
||||
@@ -123,7 +157,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
|
||||
// Only capture scroll-down events if there's room;
|
||||
// otherwise allow events to bubble.
|
||||
if (scrollTop < maxScroll) {
|
||||
if (actualScrollTop < maxScroll) {
|
||||
if (keyMatchers[Command.PAGE_DOWN](key)) {
|
||||
scrollByWithAnimation(innerHeight);
|
||||
return true;
|
||||
@@ -140,21 +174,21 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
{ isActive: hasFocus },
|
||||
);
|
||||
|
||||
const getScrollState = useCallback(
|
||||
() => ({
|
||||
scrollTop: getScrollTop(),
|
||||
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],
|
||||
);
|
||||
};
|
||||
}, [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>,
|
||||
ref: viewportRef as React.RefObject<DOMElement>,
|
||||
getScrollState,
|
||||
scrollBy: scrollByWithAnimation,
|
||||
hasFocus: hasFocusCallback,
|
||||
@@ -167,7 +201,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={ref}
|
||||
ref={viewportRefCallback}
|
||||
maxHeight={maxHeight}
|
||||
width={width ?? maxWidth}
|
||||
height={height}
|
||||
@@ -183,7 +217,12 @@ export const Scrollable: React.FC<ScrollableProps> = ({
|
||||
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">
|
||||
<Box
|
||||
ref={contentRefCallback}
|
||||
flexShrink={0}
|
||||
paddingRight={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user