diff --git a/packages/cli/examples/scrollable-list-demo.tsx b/packages/cli/examples/scrollable-list-demo.tsx new file mode 100644 index 0000000000..8d834f9105 --- /dev/null +++ b/packages/cli/examples/scrollable-list-demo.tsx @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useRef } from 'react'; +import { render, Box, Text, useInput, useStdout } from 'ink'; +import { + ScrollableList, + type ScrollableListRef, +} from '../src/ui/components/shared/ScrollableList.js'; +import { ScrollProvider } from '../src/ui/contexts/ScrollProvider.js'; +import { MouseProvider } from '../src/ui/contexts/MouseContext.js'; +import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js'; +import { + enableMouseEvents, + disableMouseEvents, +} from '../src/ui/utils/mouse.js'; + +interface Item { + id: string; + title: string; +} + +const getLorem = (index: number) => + Array(10) + .fill(null) + .map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim()) + .join('\n'); + +const Demo = () => { + const { stdout } = useStdout(); + const [size, setSize] = useState({ + columns: stdout.columns, + rows: stdout.rows, + }); + + useEffect(() => { + const onResize = () => { + setSize({ + columns: stdout.columns, + rows: stdout.rows, + }); + }; + + stdout.on('resize', onResize); + return () => { + stdout.off('resize', onResize); + }; + }, [stdout]); + + const [items, setItems] = useState(() => + Array.from({ length: 1000 }, (_, i) => ({ + id: String(i), + title: `Item ${i + 1}`, + })), + ); + + const listRef = useRef>(null); + + useInput((input, key) => { + if (input === 'a' || input === 'A') { + setItems((prev) => [ + ...prev, + { id: String(prev.length), title: `Item ${prev.length + 1}` }, + ]); + } + if ((input === 'e' || input === 'E') && !key.ctrl) { + setItems((prev) => { + if (prev.length === 0) return prev; + const lastIndex = prev.length - 1; + const lastItem = prev[lastIndex]!; + const newItem = { ...lastItem, title: lastItem.title + 'e' }; + return [...prev.slice(0, lastIndex), newItem]; + }); + } + if (key.ctrl && input === 'e') { + listRef.current?.scrollToEnd(); + } + // Let Ink handle Ctrl+C via exitOnCtrlC (default true) or handle explicitly if needed. + // For alternate buffer, explicit handling is often safer for cleanup. + if (key.escape || (key.ctrl && input === 'c')) { + process.exit(0); + } + }); + + return ( + + + + + + Press 'A' to add an item. Press 'E' to edit + last item. Press 'Ctrl+E' to scroll to end. Press + 'Esc' to exit. Mouse wheel or Shift+Up/Down to scroll. + + + ( + + + {item.title} + + + } + > + {item.title} + + {getLorem(index)} + + )} + estimatedItemHeight={() => 14} + keyExtractor={(item) => item.id} + hasFocus={true} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + initialScrollOffsetInIndex={Number.MAX_SAFE_INTEGER} + /> + + Count: {items.length} + + + + + ); +}; + +// Enable mouse reporting before rendering +enableMouseEvents(); + +// Ensure cleanup happens on exit +process.on('exit', () => { + disableMouseEvents(); +}); + +// Handle SIGINT explicitly to ensure cleanup runs if Ink doesn't catch it in time +process.on('SIGINT', () => { + process.exit(0); +}); + +render(, { alternateBuffer: true }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 37d0f915ac..8baee92063 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -72,6 +72,7 @@ import { ExtensionManager } from './config/extension-manager.js'; import { createPolicyUpdater } from './config/policy.js'; import { requestConsentNonInteractive } from './config/extensions/consent.js'; import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js'; +import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; const SLOW_RENDER_MS = 200; @@ -197,17 +198,19 @@ export async function startInteractiveUI( settings.merged.general?.debugKeystrokeLogging } > - - - - - + + + + + + + diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index b82b8ed4df..82abb7f4f3 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -18,6 +18,7 @@ import { ConfigContext } from '../ui/contexts/ConfigContext.js'; import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js'; import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; import { MouseProvider } from '../ui/contexts/MouseContext.js'; +import { ScrollProvider } from '../ui/contexts/ScrollProvider.js'; import { type Config } from '@google/gemini-cli-core'; @@ -167,14 +168,16 @@ export const renderWithProviders = ( - - {component} - + + + {component} + + diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 500e8ce06b..83bae31d1d 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -12,6 +12,12 @@ import { App } from './App.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { StreamingState } from './types.js'; import { ConfigContext } from './contexts/ConfigContext.js'; +import { SettingsContext } from './contexts/SettingsContext.js'; +import { + type SettingScope, + LoadedSettings, + type SettingsFile, +} from '../config/settings.js'; vi.mock('ink', async (importOriginal) => { const original = await importOriginal(); @@ -63,10 +69,27 @@ describe('App', () => { const mockConfig = makeFakeConfig(); + const mockSettingsFile: SettingsFile = { + settings: {}, + originalSettings: {}, + path: '/mock/path', + }; + + const mockLoadedSettings = new LoadedSettings( + mockSettingsFile, + mockSettingsFile, + mockSettingsFile, + mockSettingsFile, + true, + new Set(), + ); + const renderWithProviders = (ui: React.ReactElement, state: UIState) => render( - {ui} + + {ui} + , ); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index eb8c628257..c9d9f5719f 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -128,6 +128,7 @@ export const Composer = () => { uiState.constrainHeight ? debugConsoleMaxHeight : undefined } width={uiState.mainAreaWidth} + hasFocus={true} /> diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx index acc3f0622f..6208164a14 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -4,79 +4,114 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useRef, useCallback } from 'react'; import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { ConsoleMessageItem } from '../types.js'; -import { MaxSizedBox } from './shared/MaxSizedBox.js'; +import { + ScrollableList, + type ScrollableListRef, +} from './shared/ScrollableList.js'; interface DetailedMessagesDisplayProps { messages: ConsoleMessageItem[]; maxHeight: number | undefined; width: number; - // debugMode is not needed here if App.tsx filters debug messages before passing them. - // If DetailedMessagesDisplay should handle filtering, add debugMode prop. + hasFocus: boolean; } export const DetailedMessagesDisplay: React.FC< DetailedMessagesDisplayProps -> = ({ messages, maxHeight, width }) => { - if (messages.length === 0) { - return null; // Don't render anything if there are no messages - } +> = ({ messages, maxHeight, width, hasFocus }) => { + const scrollableListRef = useRef>(null); const borderAndPadding = 4; + + const estimatedItemHeight = useCallback( + (index: number) => { + const msg = messages[index]; + if (!msg) { + return 1; + } + const iconAndSpace = 2; + const textWidth = width - borderAndPadding - iconAndSpace; + if (textWidth <= 0) { + return 1; + } + const lines = Math.ceil((msg.content?.length || 1) / textWidth); + return Math.max(1, lines); + }, + [width, messages], + ); + + if (messages.length === 0) { + return null; + } + return ( Debug Console (F12 to close) - - {messages.map((msg, index) => { - let textColor = theme.text.primary; - let icon = '\u2139'; // Information source (ℹ) + + { + let textColor = theme.text.primary; + let icon = 'ℹ'; // Information source (ℹ) - switch (msg.type) { - case 'warn': - textColor = theme.status.warning; - icon = '\u26A0'; // Warning sign (⚠) - break; - case 'error': - textColor = theme.status.error; - icon = '\u2716'; // Heavy multiplication x (✖) - break; - case 'debug': - textColor = theme.text.secondary; // Or theme.text.secondary - icon = '\u{1F50D}'; // Left-pointing magnifying glass (🔍) - break; - case 'log': - default: - // Default textColor and icon are already set - break; - } + switch (msg.type) { + case 'warn': + textColor = theme.status.warning; + icon = '⚠'; // Warning sign (⚠) + break; + case 'error': + textColor = theme.status.error; + icon = '✖'; // Heavy multiplication x (✖) + break; + case 'debug': + textColor = theme.text.secondary; // Or theme.text.secondary + icon = '🔍'; // Left-pointing magnifying glass (🔍) + break; + case 'log': + default: + // Default textColor and icon are already set + break; + } - return ( - - {icon} - - {msg.content} - {msg.count && msg.count > 1 && ( - (x{msg.count}) - )} - - - ); - })} - + return ( + + {icon} + + {msg.content} + {msg.count && msg.count > 1 && ( + (x{msg.count}) + )} + + + ); + }} + keyExtractor={(item, index) => `${item.content}-${index}`} + estimatedItemHeight={estimatedItemHeight} + hasFocus={hasFocus} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + /> + ); }; diff --git a/packages/cli/src/ui/components/shared/Scrollable.test.tsx b/packages/cli/src/ui/components/shared/Scrollable.test.tsx new file mode 100644 index 0000000000..09e27f6af9 --- /dev/null +++ b/packages/cli/src/ui/components/shared/Scrollable.test.tsx @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { Scrollable } from './Scrollable.js'; +import { Text } from 'ink'; +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getInnerHeight: vi.fn(() => 5), + getScrollHeight: vi.fn(() => 10), + getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })), + }; +}); + +describe('', () => { + it('renders children', () => { + const { lastFrame } = renderWithProviders( + + Hello World + , + ); + expect(lastFrame()).toContain('Hello World'); + }); + + it('renders multiple children', () => { + const { lastFrame } = renderWithProviders( + + Line 1 + Line 2 + Line 3 + , + ); + expect(lastFrame()).toContain('Line 1'); + expect(lastFrame()).toContain('Line 2'); + expect(lastFrame()).toContain('Line 3'); + }); + + it('matches snapshot', () => { + const { lastFrame } = renderWithProviders( + + Line 1 + Line 2 + Line 3 + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx new file mode 100644 index 0000000000..57bfd32a65 --- /dev/null +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -0,0 +1,161 @@ +/** + * @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'; + +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 = ({ + children, + width, + height, + maxWidth, + maxHeight, + hasFocus, + scrollToBottom, + flexGrow, +}) => { + const [scrollTop, setScrollTop] = useState(0); + const ref = useRef(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. The if conditions + // prevent infinite loops. + // 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 = scrollTop >= size.scrollHeight - size.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 scrollBy = useCallback( + (delta: number) => { + const { scrollHeight, innerHeight } = sizeRef.current; + setScrollTop((prev: number) => + Math.min( + Math.max(0, prev + delta), + Math.max(0, scrollHeight - innerHeight), + ), + ); + }, + [sizeRef], + ); + + 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 getScrollState = useCallback( + () => ({ + scrollTop, + scrollHeight: size.scrollHeight, + innerHeight: size.innerHeight, + }), + [scrollTop, size.scrollHeight, size.innerHeight], + ); + + const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]); + + const scrollableEntry = useMemo( + () => ({ + ref: ref as React.RefObject, + getScrollState, + scrollBy: scrollByWithAnimation, + hasFocus: hasFocusCallback, + flashScrollbar, + }), + [getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar], + ); + + useScrollable(scrollableEntry, hasFocus && ref.current !== null); + + return ( + + {/* + 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. + */} + + {children} + + + ); +}; diff --git a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx new file mode 100644 index 0000000000..0e24a2bdba --- /dev/null +++ b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useRef, act } from 'react'; +import { render } from 'ink-testing-library'; +import { Box, Text } from 'ink'; +import { ScrollableList, type ScrollableListRef } from './ScrollableList.js'; +import { ScrollProvider } from '../../contexts/ScrollProvider.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { MouseProvider } from '../../contexts/MouseContext.js'; +import { describe, it, expect, vi } from 'vitest'; +// Mock useStdout to provide a fixed size for testing +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useStdout: () => ({ + stdout: { + columns: 80, + rows: 24, + on: vi.fn(), + off: vi.fn(), + write: vi.fn(), + }, + }), + }; +}); + +interface Item { + id: string; + title: string; +} + +const getLorem = (index: number) => + Array(10) + .fill(null) + .map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim()) + .join('\n'); + +const TestComponent = ({ + initialItems = 1000, + onAddItem, + onRef, +}: { + initialItems?: number; + onAddItem?: (addItem: () => void) => void; + onRef?: (ref: ScrollableListRef | null) => void; +}) => { + const [items, setItems] = useState(() => + Array.from({ length: initialItems }, (_, i) => ({ + id: String(i), + title: `Item ${i + 1}`, + })), + ); + + const listRef = useRef>(null); + + useEffect(() => { + onAddItem?.(() => { + setItems((prev) => [ + ...prev, + { + id: String(prev.length), + title: `Item ${prev.length + 1}`, + }, + ]); + }); + }, [onAddItem]); + + useEffect(() => { + if (onRef) { + onRef(listRef.current); + } + }, [onRef]); + + return ( + + + + + + ( + + + {item.title} + + + } + > + {item.title} + + {getLorem(index)} + + )} + estimatedItemHeight={() => 14} + keyExtractor={(item) => item.id} + hasFocus={true} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + /> + + Count: {items.length} + + + + + ); +}; +describe('ScrollableList Demo Behavior', () => { + it('should scroll to bottom when new items are added and stop when scrolled up', async () => { + let addItem: (() => void) | undefined; + let listRef: ScrollableListRef | null = null; + let lastFrame: () => string | undefined; + + await act(async () => { + const result = render( + { + addItem = add; + }} + onRef={(ref) => { + listRef = ref; + }} + />, + ); + lastFrame = result.lastFrame; + }); + + // Initial render should show Item 1000 + expect(lastFrame!()).toContain('Item 1000'); + expect(lastFrame!()).toContain('Count: 1000'); + + // Add item 1001 + await act(async () => { + addItem?.(); + }); + for (let i = 0; i < 20; i++) { + if (lastFrame!()?.includes('Count: 1001')) break; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + } + expect(lastFrame!()).toContain('Item 1001'); + expect(lastFrame!()).toContain('Count: 1001'); + expect(lastFrame!()).not.toContain('Item 990'); // Should have scrolled past it + + // Add item 1002 + await act(async () => { + addItem?.(); + }); + for (let i = 0; i < 20; i++) { + if (lastFrame!()?.includes('Count: 1002')) break; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + } + expect(lastFrame!()).toContain('Item 1002'); + expect(lastFrame!()).toContain('Count: 1002'); + expect(lastFrame!()).not.toContain('Item 991'); + + // Scroll up directly via ref + await act(async () => { + listRef?.scrollBy(-5); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Add item 1003 - should NOT be visible because we scrolled up + await act(async () => { + addItem?.(); + }); + for (let i = 0; i < 20; i++) { + if (lastFrame!()?.includes('Count: 1003')) break; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + } + expect(lastFrame!()).not.toContain('Item 1003'); + expect(lastFrame!()).toContain('Count: 1003'); + }); +}); diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx new file mode 100644 index 0000000000..d0f2cb27b1 --- /dev/null +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -0,0 +1,131 @@ +/** + * @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 = { + 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 extends VirtualizedListProps { + hasFocus: boolean; +} + +export type ScrollableListRef = VirtualizedListRef; + +function ScrollableList( + props: ScrollableListProps, + ref: React.Ref>, +) { + const { hasFocus } = props; + const virtualizedListRef = useRef>(null); + const containerRef = useRef(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, + getScrollState, + scrollBy: scrollByWithAnimation, + hasFocus: hasFocusCallback, + flashScrollbar, + }), + [getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar], + ); + + useScrollable(scrollableEntry, hasFocus); + + return ( + + + + ); +} + +const ScrollableListWithForwardRef = forwardRef(ScrollableList) as ( + props: ScrollableListProps & { ref?: React.Ref> }, +) => React.ReactElement; + +export { ScrollableListWithForwardRef as ScrollableList }; diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx new file mode 100644 index 0000000000..241a02df62 --- /dev/null +++ b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js'; +import { Text, Box } from 'ink'; +import { + createRef, + act, + useEffect, + createContext, + useContext, + useState, +} from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +describe('', () => { + const keyExtractor = (item: string) => item; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('with 10px height and 100 items', () => { + const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); + // We use 1px for items. Container is 10px. + // Viewport shows 10 items. Overscan adds 10 items. + const itemHeight = 1; + const renderItem1px = ({ item }: { item: string }) => ( + + {item} + + ); + + it.each([ + { + name: 'top', + initialScrollIndex: undefined, + visible: ['Item 0', 'Item 7'], + notVisible: ['Item 8', 'Item 15', 'Item 50', 'Item 99'], + }, + { + name: 'scrolled to bottom', + initialScrollIndex: 99, + visible: ['Item 99', 'Item 92'], + notVisible: ['Item 91', 'Item 85', 'Item 50', 'Item 0'], + }, + ])( + 'renders only visible items ($name)', + async ({ initialScrollIndex, visible, notVisible }) => { + const { lastFrame } = render( + + itemHeight} + initialScrollIndex={initialScrollIndex} + /> + , + ); + await act(async () => { + await delay(0); + }); + + const frame = lastFrame(); + visible.forEach((item) => { + expect(frame).toContain(item); + }); + notVisible.forEach((item) => { + expect(frame).not.toContain(item); + }); + expect(frame).toMatchSnapshot(); + }, + ); + + it('sticks to bottom when new items added', async () => { + const { lastFrame, rerender } = render( + + itemHeight} + initialScrollIndex={99} + /> + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toContain('Item 99'); + + // Add items + const newData = [...longData, 'Item 100', 'Item 101']; + rerender( + + itemHeight} + // We don't need to pass initialScrollIndex again for it to stick, + // but passing it doesn't hurt. The component should auto-stick because it was at bottom. + /> + , + ); + await act(async () => { + await delay(0); + }); + + const frame = lastFrame(); + expect(frame).toContain('Item 101'); + expect(frame).not.toContain('Item 0'); + }); + + it('scrolls down to show new items when requested via ref', async () => { + const ref = createRef>(); + const { lastFrame } = render( + + itemHeight} + /> + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toContain('Item 0'); + + // Scroll to bottom via ref + await act(async () => { + ref.current?.scrollToEnd(); + await delay(0); + }); + + const frame = lastFrame(); + expect(frame).toContain('Item 99'); + }); + + it.each([ + { initialScrollIndex: 0, expectedMountedCount: 5 }, + { initialScrollIndex: 500, expectedMountedCount: 6 }, + { initialScrollIndex: 999, expectedMountedCount: 5 }, + ])( + 'mounts only visible items with 1000 items and 10px height (scroll: $initialScrollIndex)', + async ({ initialScrollIndex, expectedMountedCount }) => { + let mountedCount = 0; + const tallItemHeight = 5; + const ItemWithEffect = ({ item }: { item: string }) => { + useEffect(() => { + mountedCount++; + return () => { + mountedCount--; + }; + }, []); + return ( + + {item} + + ); + }; + + const veryLongData = Array.from( + { length: 1000 }, + (_, i) => `Item ${i}`, + ); + + const { lastFrame } = render( + + ( + + )} + keyExtractor={keyExtractor} + estimatedItemHeight={() => tallItemHeight} + initialScrollIndex={initialScrollIndex} + /> + , + ); + await act(async () => { + await delay(0); + }); + + const frame = lastFrame(); + expect(mountedCount).toBe(expectedMountedCount); + expect(frame).toMatchSnapshot(); + }, + ); + }); + + it('renders more items when a visible item shrinks via context update', async () => { + const SizeContext = createContext<{ + firstItemHeight: number; + setFirstItemHeight: (h: number) => void; + }>({ + firstItemHeight: 10, + setFirstItemHeight: () => {}, + }); + + const items = Array.from({ length: 20 }, (_, i) => ({ + id: `Item ${i}`, + })); + + const ItemWithContext = ({ + item, + index, + }: { + item: { id: string }; + index: number; + }) => { + const { firstItemHeight } = useContext(SizeContext); + const height = index === 0 ? firstItemHeight : 1; + return ( + + {item.id} + + ); + }; + + const TestComponent = () => { + const [firstItemHeight, setFirstItemHeight] = useState(10); + return ( + + + ( + + )} + keyExtractor={(item) => item.id} + estimatedItemHeight={() => 1} + /> + + {/* Expose setter for testing */} + + + ); + }; + + let setHeightFn: (h: number) => void = () => {}; + const TestControl = ({ + setFirstItemHeight, + }: { + setFirstItemHeight: (h: number) => void; + }) => { + setHeightFn = setFirstItemHeight; + return null; + }; + + const { lastFrame } = render(); + await act(async () => { + await delay(0); + }); + + // Initially, only Item 0 (height 10) fills the 10px viewport + expect(lastFrame()).toContain('Item 0'); + expect(lastFrame()).not.toContain('Item 1'); + + // Shrink Item 0 to 1px via context + await act(async () => { + setHeightFn(1); + await delay(0); + }); + + // Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px + expect(lastFrame()).toContain('Item 0'); + expect(lastFrame()).toContain('Item 1'); + expect(lastFrame()).toContain('Item 9'); + }); +}); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx new file mode 100644 index 0000000000..598234a308 --- /dev/null +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -0,0 +1,492 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useState, + useRef, + useLayoutEffect, + forwardRef, + useImperativeHandle, + useEffect, + useMemo, + useCallback, +} from 'react'; +import type React from 'react'; +import { theme } from '../../semantic-colors.js'; + +import { type DOMElement, measureElement, Box } from 'ink'; + +export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER; + +type VirtualizedListProps = { + data: T[]; + renderItem: (info: { item: T; index: number }) => React.ReactElement; + estimatedItemHeight: (index: number) => number; + keyExtractor: (item: T, index: number) => string; + initialScrollIndex?: number; + initialScrollOffsetInIndex?: number; + scrollbarThumbColor?: string; +}; + +export type VirtualizedListRef = { + scrollBy: (delta: number) => void; + scrollTo: (offset: number) => void; + scrollToEnd: () => void; + scrollToIndex: (params: { + index: number; + viewOffset?: number; + viewPosition?: number; + }) => void; + scrollToItem: (params: { + item: T; + viewOffset?: number; + viewPosition?: number; + }) => void; + getScrollIndex: () => number; + getScrollState: () => { + scrollTop: number; + scrollHeight: number; + innerHeight: number; + }; +}; + +function findLastIndex( + array: T[], + predicate: (value: T, index: number, obj: T[]) => unknown, +): number { + for (let i = array.length - 1; i >= 0; i--) { + if (predicate(array[i]!, i, array)) { + return i; + } + } + return -1; +} + +function VirtualizedList( + props: VirtualizedListProps, + ref: React.Ref>, +) { + const { + data, + renderItem, + estimatedItemHeight, + keyExtractor, + initialScrollIndex, + initialScrollOffsetInIndex, + } = props; + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + const [scrollAnchor, setScrollAnchor] = useState(() => { + const scrollToEnd = + initialScrollIndex === SCROLL_TO_ITEM_END || + (typeof initialScrollIndex === 'number' && + initialScrollIndex >= data.length - 1 && + initialScrollOffsetInIndex === SCROLL_TO_ITEM_END); + + if (scrollToEnd) { + return { + index: data.length > 0 ? data.length - 1 : 0, + offset: SCROLL_TO_ITEM_END, + }; + } + + if (typeof initialScrollIndex === 'number') { + return { + index: Math.max(0, Math.min(data.length - 1, initialScrollIndex)), + offset: initialScrollOffsetInIndex ?? 0, + }; + } + + return { index: 0, offset: 0 }; + }); + const [isStickingToBottom, setIsStickingToBottom] = useState(() => { + const scrollToEnd = + initialScrollIndex === SCROLL_TO_ITEM_END || + (typeof initialScrollIndex === 'number' && + initialScrollIndex >= data.length - 1 && + initialScrollOffsetInIndex === SCROLL_TO_ITEM_END); + return scrollToEnd; + }); + const containerRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(0); + const itemRefs = useRef>([]); + const [heights, setHeights] = useState([]); + const isInitialScrollSet = useRef(false); + + const { totalHeight, offsets } = useMemo(() => { + const offsets: number[] = [0]; + let totalHeight = 0; + for (let i = 0; i < data.length; i++) { + const height = heights[i] ?? estimatedItemHeight(i); + totalHeight += height; + offsets.push(totalHeight); + } + return { totalHeight, offsets }; + }, [heights, data, estimatedItemHeight]); + + useEffect(() => { + setHeights((prevHeights) => { + if (data.length === prevHeights.length) { + return prevHeights; + } + + const newHeights = [...prevHeights]; + if (data.length < prevHeights.length) { + newHeights.length = data.length; + } else { + for (let i = prevHeights.length; i < data.length; i++) { + newHeights[i] = estimatedItemHeight(i); + } + } + return newHeights; + }); + }, [data, estimatedItemHeight]); + + // This layout effect needs to run on every render to correctly measure the + // container and ensure we recompute the layout if it has changed. + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + if (containerRef.current) { + const height = Math.round(measureElement(containerRef.current).height); + if (containerHeight !== height) { + setContainerHeight(height); + } + } + + let newHeights: number[] | null = null; + for (let i = startIndex; i <= endIndex; i++) { + const itemRef = itemRefs.current[i]; + if (itemRef) { + const height = Math.round(measureElement(itemRef).height); + if (height !== heights[i]) { + if (!newHeights) { + newHeights = [...heights]; + } + newHeights[i] = height; + } + } + } + if (newHeights) { + setHeights(newHeights); + } + }); + + const scrollableContainerHeight = containerRef.current + ? Math.round(measureElement(containerRef.current).height) + : containerHeight; + + const getAnchorForScrollTop = useCallback( + ( + scrollTop: number, + offsets: number[], + ): { index: number; offset: number } => { + const index = findLastIndex(offsets, (offset) => offset <= scrollTop); + if (index === -1) { + return { index: 0, offset: 0 }; + } + + return { index, offset: scrollTop - offsets[index]! }; + }, + [], + ); + + const scrollTop = useMemo(() => { + const offset = offsets[scrollAnchor.index]; + if (typeof offset !== 'number') { + return 0; + } + + if (scrollAnchor.offset === SCROLL_TO_ITEM_END) { + const itemHeight = heights[scrollAnchor.index] ?? 0; + return offset + itemHeight - scrollableContainerHeight; + } + + return offset + scrollAnchor.offset; + }, [scrollAnchor, offsets, heights, scrollableContainerHeight]); + + const prevDataLength = useRef(data.length); + const prevTotalHeight = useRef(totalHeight); + const prevScrollTop = useRef(scrollTop); + const prevContainerHeight = useRef(scrollableContainerHeight); + + useLayoutEffect(() => { + const contentPreviouslyFit = + prevTotalHeight.current <= prevContainerHeight.current; + const wasScrolledToBottomPixels = + prevScrollTop.current >= + prevTotalHeight.current - prevContainerHeight.current - 1; + const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels; + + // If the user was at the bottom, they are now sticking. This handles + // manually scrolling back to the bottom. + if (wasAtBottom && scrollTop >= prevScrollTop.current) { + setIsStickingToBottom(true); + } + + const listGrew = data.length > prevDataLength.current; + const containerChanged = + prevContainerHeight.current !== scrollableContainerHeight; + + // We scroll to the end if: + // 1. The list grew AND we were already at the bottom (or sticking). + // 2. We are sticking to the bottom AND the container size changed. + if ( + (listGrew && (isStickingToBottom || wasAtBottom)) || + (isStickingToBottom && containerChanged) + ) { + setScrollAnchor({ + index: data.length > 0 ? data.length - 1 : 0, + offset: SCROLL_TO_ITEM_END, + }); + // If we are scrolling to the bottom, we are by definition sticking. + if (!isStickingToBottom) { + setIsStickingToBottom(true); + } + } + // Scenario 2: The list has changed (shrunk) in a way that our + // current scroll position or anchor is invalid. We should adjust to the bottom. + else if ( + (scrollAnchor.index >= data.length || + scrollTop > totalHeight - scrollableContainerHeight) && + data.length > 0 + ) { + const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight); + setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + } else if (data.length === 0) { + // List is now empty, reset scroll to top. + setScrollAnchor({ index: 0, offset: 0 }); + } + + // Update refs for the next render cycle. + prevDataLength.current = data.length; + prevTotalHeight.current = totalHeight; + prevScrollTop.current = scrollTop; + prevContainerHeight.current = scrollableContainerHeight; + }, [ + data.length, + totalHeight, + scrollTop, + scrollableContainerHeight, + scrollAnchor.index, + getAnchorForScrollTop, + offsets, + isStickingToBottom, + ]); + + useLayoutEffect(() => { + if ( + isInitialScrollSet.current || + offsets.length <= 1 || + totalHeight <= 0 || + containerHeight <= 0 + ) { + return; + } + + if (typeof initialScrollIndex === 'number') { + const scrollToEnd = + initialScrollIndex === SCROLL_TO_ITEM_END || + (initialScrollIndex >= data.length - 1 && + initialScrollOffsetInIndex === SCROLL_TO_ITEM_END); + + if (scrollToEnd) { + setScrollAnchor({ + index: data.length - 1, + offset: SCROLL_TO_ITEM_END, + }); + setIsStickingToBottom(true); + isInitialScrollSet.current = true; + return; + } + + const index = Math.max(0, Math.min(data.length - 1, initialScrollIndex)); + const offset = initialScrollOffsetInIndex ?? 0; + const newScrollTop = (offsets[index] ?? 0) + offset; + + const clampedScrollTop = Math.max( + 0, + Math.min(totalHeight - scrollableContainerHeight, newScrollTop), + ); + + setScrollAnchor(getAnchorForScrollTop(clampedScrollTop, offsets)); + isInitialScrollSet.current = true; + } + }, [ + initialScrollIndex, + initialScrollOffsetInIndex, + offsets, + totalHeight, + containerHeight, + getAnchorForScrollTop, + data.length, + heights, + scrollableContainerHeight, + ]); + + const startIndex = Math.max( + 0, + findLastIndex(offsets, (offset) => offset <= scrollTop) - 1, + ); + const endIndexOffset = offsets.findIndex( + (offset) => offset > scrollTop + scrollableContainerHeight, + ); + const endIndex = + endIndexOffset === -1 + ? data.length - 1 + : Math.min(data.length - 1, endIndexOffset); + + const topSpacerHeight = offsets[startIndex] ?? 0; + const bottomSpacerHeight = + totalHeight - (offsets[endIndex + 1] ?? totalHeight); + + const renderedItems = []; + for (let i = startIndex; i <= endIndex; i++) { + const item = data[i]; + if (item) { + renderedItems.push( + { + itemRefs.current[i] = el; + }} + > + {renderItem({ item, index: i })} + , + ); + } + } + + useImperativeHandle( + ref, + () => ({ + scrollBy: (delta: number) => { + if (delta < 0) { + setIsStickingToBottom(false); + } + const currentScrollTop = scrollTop; + const newScrollTop = Math.max( + 0, + Math.min( + totalHeight - scrollableContainerHeight, + currentScrollTop + delta, + ), + ); + setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + }, + scrollTo: (offset: number) => { + setIsStickingToBottom(false); + const newScrollTop = Math.max( + 0, + Math.min(totalHeight - scrollableContainerHeight, offset), + ); + setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + }, + scrollToEnd: () => { + setIsStickingToBottom(true); + if (data.length > 0) { + setScrollAnchor({ + index: data.length - 1, + offset: SCROLL_TO_ITEM_END, + }); + } + }, + scrollToIndex: ({ + index, + viewOffset = 0, + viewPosition = 0, + }: { + index: number; + viewOffset?: number; + viewPosition?: number; + }) => { + setIsStickingToBottom(false); + const offset = offsets[index]; + if (offset !== undefined) { + const newScrollTop = Math.max( + 0, + Math.min( + totalHeight - scrollableContainerHeight, + offset - viewPosition * scrollableContainerHeight + viewOffset, + ), + ); + setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + } + }, + scrollToItem: ({ + item, + viewOffset = 0, + viewPosition = 0, + }: { + item: T; + viewOffset?: number; + viewPosition?: number; + }) => { + setIsStickingToBottom(false); + const index = data.indexOf(item); + if (index !== -1) { + const offset = offsets[index]; + if (offset !== undefined) { + const newScrollTop = Math.max( + 0, + Math.min( + totalHeight - scrollableContainerHeight, + offset - viewPosition * scrollableContainerHeight + viewOffset, + ), + ); + setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + } + } + }, + getScrollIndex: () => scrollAnchor.index, + getScrollState: () => ({ + scrollTop, + scrollHeight: totalHeight, + innerHeight: containerHeight, + }), + }), + [ + offsets, + scrollAnchor, + totalHeight, + getAnchorForScrollTop, + data, + scrollableContainerHeight, + scrollTop, + containerHeight, + ], + ); + + return ( + + + + {renderedItems} + + + + ); +} + +const VirtualizedListWithForwardRef = forwardRef(VirtualizedList) as ( + props: VirtualizedListProps & { ref?: React.Ref> }, +) => React.ReactElement; + +export { VirtualizedListWithForwardRef as VirtualizedList }; + +VirtualizedList.displayName = 'VirtualizedList'; diff --git a/packages/cli/src/ui/components/shared/__snapshots__/Scrollable.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/Scrollable.test.tsx.snap new file mode 100644 index 0000000000..6c03383f14 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/Scrollable.test.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > matches snapshot 1`] = ` +"Line 1 +Line 2 +Line 3 + +" +`; diff --git a/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap new file mode 100644 index 0000000000..2636edd915 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/VirtualizedList.test.tsx.snap @@ -0,0 +1,96 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: +0) 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│Item 0 █│ +│ │ +│ │ +│ │ +│ │ +│Item 1 │ +│ │ +│ │ +│ │ +│ │ +│Item 2 │ +│ │ +│ │ +│ │ +│ │ +│Item 3 │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 500) 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│Item 500 │ +│ │ +│ │ +│ │ +│ │ +│Item 501 │ +│ │ +│ │ +│ ▄│ +│ ▀│ +│Item 502 │ +│ │ +│ │ +│ │ +│ │ +│Item 503 │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 999) 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│Item 997 │ +│ │ +│ │ +│ │ +│ │ +│Item 998 │ +│ │ +│ │ +│ │ +│ │ +│Item 999 │ +│ │ +│ │ +│ │ +│ █│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > with 10px height and 100 items > renders only visible items ('scrolled to bottom') 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│Item 92 │ +│Item 93 │ +│Item 94 │ +│Item 95 │ +│Item 96 │ +│Item 97 │ +│Item 98 │ +│Item 99 █│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[` > with 10px height and 100 items > renders only visible items ('top') 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│Item 0 █│ +│Item 1 │ +│Item 2 │ +│Item 3 │ +│Item 4 │ +│Item 5 │ +│Item 6 │ +│Item 7 │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/contexts/ScrollProvider.tsx b/packages/cli/src/ui/contexts/ScrollProvider.tsx new file mode 100644 index 0000000000..b11d25431b --- /dev/null +++ b/packages/cli/src/ui/contexts/ScrollProvider.tsx @@ -0,0 +1,184 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { getBoundingBox, type DOMElement } from 'ink'; +import { useMouse, type MouseEvent } from '../hooks/useMouse.js'; + +export interface ScrollState { + scrollTop: number; + scrollHeight: number; + innerHeight: number; +} + +export interface ScrollableEntry { + id: string; + ref: React.RefObject; + getScrollState: () => ScrollState; + scrollBy: (delta: number) => void; + hasFocus: () => boolean; + flashScrollbar: () => void; +} + +interface ScrollContextType { + register: (entry: ScrollableEntry) => void; + unregister: (id: string) => void; +} + +const ScrollContext = createContext(null); + +const findScrollableCandidates = ( + mouseEvent: MouseEvent, + scrollables: Map, +) => { + const candidates: Array = []; + + for (const entry of scrollables.values()) { + if (!entry.ref.current || !entry.hasFocus()) { + continue; + } + + const boundingBox = getBoundingBox(entry.ref.current); + if (!boundingBox) continue; + + const { x, y, width, height } = boundingBox; + + const isInside = + mouseEvent.col >= x && + mouseEvent.col < x + width + 1 && // Intentionally add one to width to include scrollbar. + mouseEvent.row >= y && + mouseEvent.row < y + height; + + if (isInside) { + candidates.push({ ...entry, area: width * height }); + } + } + + // Sort by smallest area first + candidates.sort((a, b) => a.area - b.area); + return candidates; +}; + +export const ScrollProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [scrollables, setScrollables] = useState( + new Map(), + ); + + const register = useCallback((entry: ScrollableEntry) => { + setScrollables((prev) => new Map(prev).set(entry.id, entry)); + }, []); + + const unregister = useCallback((id: string) => { + setScrollables((prev) => { + const next = new Map(prev); + next.delete(id); + return next; + }); + }, []); + + const scrollablesRef = useRef(scrollables); + useEffect(() => { + scrollablesRef.current = scrollables; + }, [scrollables]); + + const handleScroll = (direction: 'up' | 'down', mouseEvent: MouseEvent) => { + const delta = direction === 'up' ? -1 : 1; + const candidates = findScrollableCandidates( + mouseEvent, + scrollablesRef.current, + ); + + for (const candidate of candidates) { + const { scrollTop, scrollHeight, innerHeight } = + candidate.getScrollState(); + + // Epsilon to handle floating point inaccuracies. + const canScrollUp = scrollTop > 0.001; + const canScrollDown = scrollTop < scrollHeight - innerHeight - 0.001; + + if (direction === 'up' && canScrollUp) { + candidate.scrollBy(delta); + return; + } + + if (direction === 'down' && canScrollDown) { + candidate.scrollBy(delta); + return; + } + } + }; + + const handleClick = (mouseEvent: MouseEvent) => { + const candidates = findScrollableCandidates( + mouseEvent, + scrollablesRef.current, + ); + + if (candidates.length > 0) { + // The first candidate is the innermost one. + candidates[0].flashScrollbar(); + } + }; + + useMouse( + (event: MouseEvent) => { + if (event.name === 'scroll-up') { + handleScroll('up', event); + } else if (event.name === 'scroll-down') { + handleScroll('down', event); + } else if (event.name === 'left-press') { + handleClick(event); + } + }, + { isActive: true }, + ); + + const contextValue = useMemo( + () => ({ register, unregister }), + [register, unregister], + ); + + return ( + + {children} + + ); +}; + +let nextId = 0; + +export const useScrollable = ( + entry: Omit, + isActive: boolean, +) => { + const context = useContext(ScrollContext); + if (!context) { + throw new Error('useScrollable must be used within a ScrollProvider'); + } + + const [id] = useState(() => `scrollable-${nextId++}`); + + useEffect(() => { + if (isActive) { + context.register({ ...entry, id }); + return () => { + context.unregister(id); + }; + } + return; + }, [context, entry, id, isActive]); +}; diff --git a/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts b/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts new file mode 100644 index 0000000000..fa290f5b54 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useRef, useCallback } from 'react'; +import { theme } from '../semantic-colors.js'; +import { interpolateColor } from '../themes/color-utils.js'; + +export function useAnimatedScrollbar( + isFocused: boolean, + scrollBy: (delta: number) => void, +) { + const [scrollbarColor, setScrollbarColor] = useState(theme.ui.dark); + const colorRef = useRef(scrollbarColor); + colorRef.current = scrollbarColor; + + const animationFrame = useRef(null); + const timeout = useRef(null); + + const cleanup = useCallback(() => { + if (animationFrame.current) { + clearInterval(animationFrame.current); + animationFrame.current = null; + } + if (timeout.current) { + clearTimeout(timeout.current); + timeout.current = null; + } + }, []); + + const flashScrollbar = useCallback(() => { + cleanup(); + + const fadeInDuration = 200; + const visibleDuration = 1000; + const fadeOutDuration = 300; + + const focusedColor = theme.text.secondary; + const unfocusedColor = theme.ui.dark; + const startColor = colorRef.current; + + // Phase 1: Fade In + let start = Date.now(); + const animateFadeIn = () => { + const elapsed = Date.now() - start; + const progress = Math.min(elapsed / fadeInDuration, 1); + + setScrollbarColor(interpolateColor(startColor, focusedColor, progress)); + + if (progress === 1) { + if (animationFrame.current) { + clearInterval(animationFrame.current); + animationFrame.current = null; + } + + // Phase 2: Wait + timeout.current = setTimeout(() => { + // Phase 3: Fade Out + start = Date.now(); + const animateFadeOut = () => { + const elapsed = Date.now() - start; + const progress = Math.min(elapsed / fadeOutDuration, 1); + setScrollbarColor( + interpolateColor(focusedColor, unfocusedColor, progress), + ); + + if (progress === 1) { + if (animationFrame.current) { + clearInterval(animationFrame.current); + animationFrame.current = null; + } + } + }; + + animationFrame.current = setInterval(animateFadeOut, 33); + }, visibleDuration); + } + }; + + animationFrame.current = setInterval(animateFadeIn, 33); + }, [cleanup]); + + const wasFocused = useRef(isFocused); + useEffect(() => { + if (isFocused && !wasFocused.current) { + flashScrollbar(); + } else if (!isFocused && wasFocused.current) { + cleanup(); + setScrollbarColor(theme.ui.dark); + } + wasFocused.current = isFocused; + return cleanup; + }, [isFocused, flashScrollbar, cleanup]); + + const scrollByWithAnimation = useCallback( + (delta: number) => { + scrollBy(delta); + flashScrollbar(); + }, + [scrollBy, flashScrollbar], + ); + + return { scrollbarColor, flashScrollbar, scrollByWithAnimation }; +} diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 2f92b77a23..db715343f1 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -13,10 +13,12 @@ import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useFlickerDetector } from '../hooks/useFlickerDetector.js'; +import { useSettings } from '../contexts/SettingsContext.js'; export const DefaultAppLayout: React.FC = () => { const uiState = useUIState(); const { rootUiRef, terminalHeight } = uiState; + const settings = useSettings(); useFlickerDetector(rootUiRef, terminalHeight); return ( @@ -24,6 +26,12 @@ export const DefaultAppLayout: React.FC = () => { flexDirection="column" width={uiState.mainAreaWidth} ref={uiState.rootUiRef} + height={ + settings.merged.ui?.useAlternateBuffer ? terminalHeight - 1 : undefined + } + flexShrink={0} + flexGrow={0} + overflow="hidden" >