mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
Scrollable support (#12544)
This commit is contained in:
165
packages/cli/examples/scrollable-list-demo.tsx
Normal file
165
packages/cli/examples/scrollable-list-demo.tsx
Normal file
@@ -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<Item[]>(() =>
|
||||
Array.from({ length: 1000 }, (_, i) => ({
|
||||
id: String(i),
|
||||
title: `Item ${i + 1}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const listRef = useRef<ScrollableListRef<Item>>(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 (
|
||||
<MouseProvider mouseEventsEnabled={true}>
|
||||
<KeypressProvider>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={size.columns}
|
||||
height={size.rows - 1}
|
||||
padding={1}
|
||||
>
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
|
||||
<ScrollableList
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={({ item, index }) => (
|
||||
<Box flexDirection="column" paddingBottom={2}>
|
||||
<Box
|
||||
sticky
|
||||
flexDirection="column"
|
||||
width={size.columns - 2}
|
||||
opaque
|
||||
stickyChildren={
|
||||
<Box
|
||||
flexDirection="column"
|
||||
width={size.columns - 2}
|
||||
opaque
|
||||
>
|
||||
<Text>{item.title}</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor="gray"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Text>{item.title}</Text>
|
||||
</Box>
|
||||
<Text color="gray">{getLorem(index)}</Text>
|
||||
</Box>
|
||||
)}
|
||||
estimatedItemHeight={() => 14}
|
||||
keyExtractor={(item) => item.id}
|
||||
hasFocus={true}
|
||||
initialScrollIndex={Number.MAX_SAFE_INTEGER}
|
||||
initialScrollOffsetInIndex={Number.MAX_SAFE_INTEGER}
|
||||
/>
|
||||
</Box>
|
||||
<Text>Count: {items.length}</Text>
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</KeypressProvider>
|
||||
</MouseProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// 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(<Demo />, { alternateBuffer: true });
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
<ScrollProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
|
||||
@@ -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 = (
|
||||
<ShellFocusContext.Provider value={shellFocus}>
|
||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
<ScrollProvider>
|
||||
<Box
|
||||
width={terminalWidth}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
flexDirection="column"
|
||||
>
|
||||
{component}
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</ShellFocusContext.Provider>
|
||||
|
||||
@@ -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<typeof import('ink')>();
|
||||
@@ -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<SettingScope>(),
|
||||
);
|
||||
|
||||
const renderWithProviders = (ui: React.ReactElement, state: UIState) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
<UIStateContext.Provider value={state}>{ui}</UIStateContext.Provider>
|
||||
<SettingsContext.Provider value={mockLoadedSettings}>
|
||||
<UIStateContext.Provider value={state}>{ui}</UIStateContext.Provider>
|
||||
</SettingsContext.Provider>
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ export const Composer = () => {
|
||||
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
|
||||
}
|
||||
width={uiState.mainAreaWidth}
|
||||
hasFocus={true}
|
||||
/>
|
||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||
</Box>
|
||||
|
||||
@@ -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<ScrollableListRef<ConsoleMessageItem>>(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 (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingX={1}
|
||||
paddingLeft={1}
|
||||
width={width}
|
||||
height={maxHeight}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
|
||||
{messages.map((msg, index) => {
|
||||
let textColor = theme.text.primary;
|
||||
let icon = '\u2139'; // Information source (ℹ)
|
||||
<Box height={maxHeight} width={width - borderAndPadding}>
|
||||
<ScrollableList
|
||||
ref={scrollableListRef}
|
||||
data={messages}
|
||||
renderItem={({ item: msg }: { item: ConsoleMessageItem }) => {
|
||||
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 (
|
||||
<Box key={index} flexDirection="row">
|
||||
<Text color={textColor}>{icon} </Text>
|
||||
<Text color={textColor} wrap="wrap">
|
||||
{msg.content}
|
||||
{msg.count && msg.count > 1 && (
|
||||
<Text color={theme.text.secondary}> (x{msg.count})</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</MaxSizedBox>
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text color={textColor}>{icon} </Text>
|
||||
<Text color={textColor} wrap="wrap">
|
||||
{msg.content}
|
||||
{msg.count && msg.count > 1 && (
|
||||
<Text color={theme.text.secondary}> (x{msg.count})</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
keyExtractor={(item, index) => `${item.content}-${index}`}
|
||||
estimatedItemHeight={estimatedItemHeight}
|
||||
hasFocus={hasFocus}
|
||||
initialScrollIndex={Number.MAX_SAFE_INTEGER}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
55
packages/cli/src/ui/components/shared/Scrollable.test.tsx
Normal file
55
packages/cli/src/ui/components/shared/Scrollable.test.tsx
Normal file
@@ -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<typeof import('ink')>();
|
||||
return {
|
||||
...actual,
|
||||
getInnerHeight: vi.fn(() => 5),
|
||||
getScrollHeight: vi.fn(() => 10),
|
||||
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })),
|
||||
};
|
||||
});
|
||||
|
||||
describe('<Scrollable />', () => {
|
||||
it('renders children', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<Scrollable hasFocus={false} height={5}>
|
||||
<Text>Hello World</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Hello World');
|
||||
});
|
||||
|
||||
it('renders multiple children', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<Scrollable hasFocus={false} height={5}>
|
||||
<Text>Line 1</Text>
|
||||
<Text>Line 2</Text>
|
||||
<Text>Line 3</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
expect(lastFrame()).toContain('Line 1');
|
||||
expect(lastFrame()).toContain('Line 2');
|
||||
expect(lastFrame()).toContain('Line 3');
|
||||
});
|
||||
|
||||
it('matches snapshot', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<Scrollable hasFocus={false} height={5}>
|
||||
<Text>Line 1</Text>
|
||||
<Text>Line 2</Text>
|
||||
<Text>Line 3</Text>
|
||||
</Scrollable>,
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
161
packages/cli/src/ui/components/shared/Scrollable.tsx
Normal file
161
packages/cli/src/ui/components/shared/Scrollable.tsx
Normal file
@@ -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<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. 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<DOMElement>,
|
||||
getScrollState,
|
||||
scrollBy: scrollByWithAnimation,
|
||||
hasFocus: hasFocusCallback,
|
||||
flashScrollbar,
|
||||
}),
|
||||
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
|
||||
);
|
||||
|
||||
useScrollable(scrollableEntry, hasFocus && ref.current !== null);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
200
packages/cli/src/ui/components/shared/ScrollableList.test.tsx
Normal file
200
packages/cli/src/ui/components/shared/ScrollableList.test.tsx
Normal file
@@ -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<typeof import('ink')>();
|
||||
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<Item> | null) => void;
|
||||
}) => {
|
||||
const [items, setItems] = useState<Item[]>(() =>
|
||||
Array.from({ length: initialItems }, (_, i) => ({
|
||||
id: String(i),
|
||||
title: `Item ${i + 1}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const listRef = useRef<ScrollableListRef<Item>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onAddItem?.(() => {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: String(prev.length),
|
||||
title: `Item ${prev.length + 1}`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}, [onAddItem]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onRef) {
|
||||
onRef(listRef.current);
|
||||
}
|
||||
}, [onRef]);
|
||||
|
||||
return (
|
||||
<MouseProvider mouseEventsEnabled={false}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ScrollProvider>
|
||||
<Box flexDirection="column" width={80} height={24} padding={1}>
|
||||
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
|
||||
<ScrollableList
|
||||
ref={listRef}
|
||||
data={items}
|
||||
renderItem={({ item, index }) => (
|
||||
<Box flexDirection="column" paddingBottom={2}>
|
||||
<Box
|
||||
sticky
|
||||
flexDirection="column"
|
||||
width={78}
|
||||
opaque
|
||||
stickyChildren={
|
||||
<Box flexDirection="column" width={78} opaque>
|
||||
<Text>{item.title}</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={true}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderColor="gray"
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Text>{item.title}</Text>
|
||||
</Box>
|
||||
<Text color="gray">{getLorem(index)}</Text>
|
||||
</Box>
|
||||
)}
|
||||
estimatedItemHeight={() => 14}
|
||||
keyExtractor={(item) => item.id}
|
||||
hasFocus={true}
|
||||
initialScrollIndex={Number.MAX_SAFE_INTEGER}
|
||||
/>
|
||||
</Box>
|
||||
<Text>Count: {items.length}</Text>
|
||||
</Box>
|
||||
</ScrollProvider>
|
||||
</KeypressProvider>
|
||||
</MouseProvider>
|
||||
);
|
||||
};
|
||||
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<Item> | null = null;
|
||||
let lastFrame: () => string | undefined;
|
||||
|
||||
await act(async () => {
|
||||
const result = render(
|
||||
<TestComponent
|
||||
onAddItem={(add) => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
131
packages/cli/src/ui/components/shared/ScrollableList.tsx
Normal file
131
packages/cli/src/ui/components/shared/ScrollableList.tsx
Normal file
@@ -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<T> = {
|
||||
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<T> extends VirtualizedListProps<T> {
|
||||
hasFocus: boolean;
|
||||
}
|
||||
|
||||
export type ScrollableListRef<T> = VirtualizedListRef<T>;
|
||||
|
||||
function ScrollableList<T>(
|
||||
props: ScrollableListProps<T>,
|
||||
ref: React.Ref<ScrollableListRef<T>>,
|
||||
) {
|
||||
const { hasFocus } = props;
|
||||
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(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<DOMElement>,
|
||||
getScrollState,
|
||||
scrollBy: scrollByWithAnimation,
|
||||
hasFocus: hasFocusCallback,
|
||||
flashScrollbar,
|
||||
}),
|
||||
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
|
||||
);
|
||||
|
||||
useScrollable(scrollableEntry, hasFocus);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
>
|
||||
<VirtualizedList
|
||||
ref={virtualizedListRef}
|
||||
{...props}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const ScrollableListWithForwardRef = forwardRef(ScrollableList) as <T>(
|
||||
props: ScrollableListProps<T> & { ref?: React.Ref<ScrollableListRef<T>> },
|
||||
) => React.ReactElement;
|
||||
|
||||
export { ScrollableListWithForwardRef as ScrollableList };
|
||||
283
packages/cli/src/ui/components/shared/VirtualizedList.test.tsx
Normal file
283
packages/cli/src/ui/components/shared/VirtualizedList.test.tsx
Normal file
@@ -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('<VirtualizedList />', () => {
|
||||
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 }) => (
|
||||
<Box height={itemHeight}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => itemHeight}
|
||||
initialScrollIndex={initialScrollIndex}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
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(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => itemHeight}
|
||||
initialScrollIndex={99}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
expect(lastFrame()).toContain('Item 99');
|
||||
|
||||
// Add items
|
||||
const newData = [...longData, 'Item 100', 'Item 101'];
|
||||
rerender(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={newData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 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.
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
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<VirtualizedListRef<string>>();
|
||||
const { lastFrame } = render(
|
||||
<Box height={10} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => itemHeight}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
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 (
|
||||
<Box height={tallItemHeight}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const veryLongData = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => `Item ${i}`,
|
||||
);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<Box height={20} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={veryLongData}
|
||||
renderItem={({ item }) => (
|
||||
<ItemWithEffect key={item} item={item} />
|
||||
)}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => tallItemHeight}
|
||||
initialScrollIndex={initialScrollIndex}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
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 (
|
||||
<Box height={height}>
|
||||
<Text>{item.id}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const TestComponent = () => {
|
||||
const [firstItemHeight, setFirstItemHeight] = useState(10);
|
||||
return (
|
||||
<SizeContext.Provider value={{ firstItemHeight, setFirstItemHeight }}>
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={items}
|
||||
renderItem={({ item, index }) => (
|
||||
<ItemWithContext item={item} index={index} />
|
||||
)}
|
||||
keyExtractor={(item) => item.id}
|
||||
estimatedItemHeight={() => 1}
|
||||
/>
|
||||
</Box>
|
||||
{/* Expose setter for testing */}
|
||||
<TestControl setFirstItemHeight={setFirstItemHeight} />
|
||||
</SizeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
let setHeightFn: (h: number) => void = () => {};
|
||||
const TestControl = ({
|
||||
setFirstItemHeight,
|
||||
}: {
|
||||
setFirstItemHeight: (h: number) => void;
|
||||
}) => {
|
||||
setHeightFn = setFirstItemHeight;
|
||||
return null;
|
||||
};
|
||||
|
||||
const { lastFrame } = render(<TestComponent />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
492
packages/cli/src/ui/components/shared/VirtualizedList.tsx
Normal file
492
packages/cli/src/ui/components/shared/VirtualizedList.tsx
Normal file
@@ -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<T> = {
|
||||
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<T> = {
|
||||
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<T>(
|
||||
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<T>(
|
||||
props: VirtualizedListProps<T>,
|
||||
ref: React.Ref<VirtualizedListRef<T>>,
|
||||
) {
|
||||
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<DOMElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const itemRefs = useRef<Array<DOMElement | null>>([]);
|
||||
const [heights, setHeights] = useState<number[]>([]);
|
||||
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(
|
||||
<Box
|
||||
key={keyExtractor(item, i)}
|
||||
width="100%"
|
||||
ref={(el) => {
|
||||
itemRefs.current[i] = el;
|
||||
}}
|
||||
>
|
||||
{renderItem({ item, index: i })}
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const VirtualizedListWithForwardRef = forwardRef(VirtualizedList) as <T>(
|
||||
props: VirtualizedListProps<T> & { ref?: React.Ref<VirtualizedListRef<T>> },
|
||||
) => React.ReactElement;
|
||||
|
||||
export { VirtualizedListWithForwardRef as VirtualizedList };
|
||||
|
||||
VirtualizedList.displayName = 'VirtualizedList';
|
||||
@@ -0,0 +1,9 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<Scrollable /> > matches snapshot 1`] = `
|
||||
"Line 1
|
||||
Line 2
|
||||
Line 3
|
||||
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,96 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<VirtualizedList /> > 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[`<VirtualizedList /> > 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[`<VirtualizedList /> > 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[`<VirtualizedList /> > 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[`<VirtualizedList /> > 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 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
184
packages/cli/src/ui/contexts/ScrollProvider.tsx
Normal file
184
packages/cli/src/ui/contexts/ScrollProvider.tsx
Normal file
@@ -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<DOMElement>;
|
||||
getScrollState: () => ScrollState;
|
||||
scrollBy: (delta: number) => void;
|
||||
hasFocus: () => boolean;
|
||||
flashScrollbar: () => void;
|
||||
}
|
||||
|
||||
interface ScrollContextType {
|
||||
register: (entry: ScrollableEntry) => void;
|
||||
unregister: (id: string) => void;
|
||||
}
|
||||
|
||||
const ScrollContext = createContext<ScrollContextType | null>(null);
|
||||
|
||||
const findScrollableCandidates = (
|
||||
mouseEvent: MouseEvent,
|
||||
scrollables: Map<string, ScrollableEntry>,
|
||||
) => {
|
||||
const candidates: Array<ScrollableEntry & { area: number }> = [];
|
||||
|
||||
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<string, ScrollableEntry>(),
|
||||
);
|
||||
|
||||
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 (
|
||||
<ScrollContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ScrollContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export const useScrollable = (
|
||||
entry: Omit<ScrollableEntry, 'id'>,
|
||||
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]);
|
||||
};
|
||||
106
packages/cli/src/ui/hooks/useAnimatedScrollbar.ts
Normal file
106
packages/cli/src/ui/hooks/useAnimatedScrollbar.ts
Normal file
@@ -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<NodeJS.Timeout | null>(null);
|
||||
const timeout = useRef<NodeJS.Timeout | null>(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 };
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<MainContent />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user