Download packages/cli/src/ui/components/shared/Scrollable.tsx from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 8.03 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/components/shared/Scrollable.tsx
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/ui/components/shared/Scrollable.tsx
-
curl -L -o Scrollable.tsx https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/components/shared/Scrollable.tsx
8.03 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import type React from 'react'; | |
| import { | |
| useState, | |
| useRef, | |
| useCallback, | |
| useMemo, | |
| useLayoutEffect, | |
| useEffect, | |
| useId, | |
| } 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'; | |
| interface ScrollableProps { | |
| children?: React.ReactNode; | |
| 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, | |
| width, | |
| height, | |
| maxWidth, | |
| maxHeight, | |
| hasFocus, | |
| scrollToBottom, | |
| flexGrow, | |
| reportOverflow = false, | |
| overflowToBackbuffer, | |
| scrollbar = true, | |
| stableScrollback, | |
| }) => { | |
| const keyMatchers = useKeyMatchers(); | |
| const [scrollTop, setScrollTop] = useState(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 (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> | |
| ); | |
| }; | |