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 { createPolicyUpdater } from './config/policy.js';
|
||||||
import { requestConsentNonInteractive } from './config/extensions/consent.js';
|
import { requestConsentNonInteractive } from './config/extensions/consent.js';
|
||||||
import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js';
|
import { disableMouseEvents, enableMouseEvents } from './ui/utils/mouse.js';
|
||||||
|
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||||
|
|
||||||
const SLOW_RENDER_MS = 200;
|
const SLOW_RENDER_MS = 200;
|
||||||
|
|
||||||
@@ -197,17 +198,19 @@ export async function startInteractiveUI(
|
|||||||
settings.merged.general?.debugKeystrokeLogging
|
settings.merged.general?.debugKeystrokeLogging
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SessionStatsProvider>
|
<ScrollProvider>
|
||||||
<VimModeProvider settings={settings}>
|
<SessionStatsProvider>
|
||||||
<AppContainer
|
<VimModeProvider settings={settings}>
|
||||||
config={config}
|
<AppContainer
|
||||||
settings={settings}
|
config={config}
|
||||||
startupWarnings={startupWarnings}
|
settings={settings}
|
||||||
version={version}
|
startupWarnings={startupWarnings}
|
||||||
initializationResult={initializationResult}
|
version={version}
|
||||||
/>
|
initializationResult={initializationResult}
|
||||||
</VimModeProvider>
|
/>
|
||||||
</SessionStatsProvider>
|
</VimModeProvider>
|
||||||
|
</SessionStatsProvider>
|
||||||
|
</ScrollProvider>
|
||||||
</MouseProvider>
|
</MouseProvider>
|
||||||
</KeypressProvider>
|
</KeypressProvider>
|
||||||
</SettingsContext.Provider>
|
</SettingsContext.Provider>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ConfigContext } from '../ui/contexts/ConfigContext.js';
|
|||||||
import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js';
|
import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js';
|
||||||
import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
|
import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
|
||||||
import { MouseProvider } from '../ui/contexts/MouseContext.js';
|
import { MouseProvider } from '../ui/contexts/MouseContext.js';
|
||||||
|
import { ScrollProvider } from '../ui/contexts/ScrollProvider.js';
|
||||||
|
|
||||||
import { type Config } from '@google/gemini-cli-core';
|
import { type Config } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
@@ -167,14 +168,16 @@ export const renderWithProviders = (
|
|||||||
<ShellFocusContext.Provider value={shellFocus}>
|
<ShellFocusContext.Provider value={shellFocus}>
|
||||||
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
<KeypressProvider kittyProtocolEnabled={kittyProtocolEnabled}>
|
||||||
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
|
||||||
<Box
|
<ScrollProvider>
|
||||||
width={terminalWidth}
|
<Box
|
||||||
flexShrink={0}
|
width={terminalWidth}
|
||||||
flexGrow={0}
|
flexShrink={0}
|
||||||
flexDirection="column"
|
flexGrow={0}
|
||||||
>
|
flexDirection="column"
|
||||||
{component}
|
>
|
||||||
</Box>
|
{component}
|
||||||
|
</Box>
|
||||||
|
</ScrollProvider>
|
||||||
</MouseProvider>
|
</MouseProvider>
|
||||||
</KeypressProvider>
|
</KeypressProvider>
|
||||||
</ShellFocusContext.Provider>
|
</ShellFocusContext.Provider>
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import { App } from './App.js';
|
|||||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||||
import { StreamingState } from './types.js';
|
import { StreamingState } from './types.js';
|
||||||
import { ConfigContext } from './contexts/ConfigContext.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) => {
|
vi.mock('ink', async (importOriginal) => {
|
||||||
const original = await importOriginal<typeof import('ink')>();
|
const original = await importOriginal<typeof import('ink')>();
|
||||||
@@ -63,10 +69,27 @@ describe('App', () => {
|
|||||||
|
|
||||||
const mockConfig = makeFakeConfig();
|
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) =>
|
const renderWithProviders = (ui: React.ReactElement, state: UIState) =>
|
||||||
render(
|
render(
|
||||||
<ConfigContext.Provider value={mockConfig}>
|
<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>,
|
</ConfigContext.Provider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const Composer = () => {
|
|||||||
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
|
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
|
||||||
}
|
}
|
||||||
width={uiState.mainAreaWidth}
|
width={uiState.mainAreaWidth}
|
||||||
|
hasFocus={true}
|
||||||
/>
|
/>
|
||||||
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -4,79 +4,114 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useRef, useCallback } from 'react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import type { ConsoleMessageItem } from '../types.js';
|
import type { ConsoleMessageItem } from '../types.js';
|
||||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
import {
|
||||||
|
ScrollableList,
|
||||||
|
type ScrollableListRef,
|
||||||
|
} from './shared/ScrollableList.js';
|
||||||
|
|
||||||
interface DetailedMessagesDisplayProps {
|
interface DetailedMessagesDisplayProps {
|
||||||
messages: ConsoleMessageItem[];
|
messages: ConsoleMessageItem[];
|
||||||
maxHeight: number | undefined;
|
maxHeight: number | undefined;
|
||||||
width: number;
|
width: number;
|
||||||
// debugMode is not needed here if App.tsx filters debug messages before passing them.
|
hasFocus: boolean;
|
||||||
// If DetailedMessagesDisplay should handle filtering, add debugMode prop.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DetailedMessagesDisplay: React.FC<
|
export const DetailedMessagesDisplay: React.FC<
|
||||||
DetailedMessagesDisplayProps
|
DetailedMessagesDisplayProps
|
||||||
> = ({ messages, maxHeight, width }) => {
|
> = ({ messages, maxHeight, width, hasFocus }) => {
|
||||||
if (messages.length === 0) {
|
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
|
||||||
return null; // Don't render anything if there are no messages
|
|
||||||
}
|
|
||||||
|
|
||||||
const borderAndPadding = 4;
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
marginTop={1}
|
marginTop={1}
|
||||||
borderStyle="round"
|
borderStyle="round"
|
||||||
borderColor={theme.border.default}
|
borderColor={theme.border.default}
|
||||||
paddingX={1}
|
paddingLeft={1}
|
||||||
width={width}
|
width={width}
|
||||||
|
height={maxHeight}
|
||||||
|
flexShrink={0}
|
||||||
|
flexGrow={0}
|
||||||
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<Box marginBottom={1}>
|
<Box marginBottom={1}>
|
||||||
<Text bold color={theme.text.primary}>
|
<Text bold color={theme.text.primary}>
|
||||||
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
|
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<MaxSizedBox maxHeight={maxHeight} maxWidth={width - borderAndPadding}>
|
<Box height={maxHeight} width={width - borderAndPadding}>
|
||||||
{messages.map((msg, index) => {
|
<ScrollableList
|
||||||
let textColor = theme.text.primary;
|
ref={scrollableListRef}
|
||||||
let icon = '\u2139'; // Information source (ℹ)
|
data={messages}
|
||||||
|
renderItem={({ item: msg }: { item: ConsoleMessageItem }) => {
|
||||||
|
let textColor = theme.text.primary;
|
||||||
|
let icon = 'ℹ'; // Information source (ℹ)
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'warn':
|
case 'warn':
|
||||||
textColor = theme.status.warning;
|
textColor = theme.status.warning;
|
||||||
icon = '\u26A0'; // Warning sign (⚠)
|
icon = '⚠'; // Warning sign (⚠)
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
textColor = theme.status.error;
|
textColor = theme.status.error;
|
||||||
icon = '\u2716'; // Heavy multiplication x (✖)
|
icon = '✖'; // Heavy multiplication x (✖)
|
||||||
break;
|
break;
|
||||||
case 'debug':
|
case 'debug':
|
||||||
textColor = theme.text.secondary; // Or theme.text.secondary
|
textColor = theme.text.secondary; // Or theme.text.secondary
|
||||||
icon = '\u{1F50D}'; // Left-pointing magnifying glass (🔍)
|
icon = '🔍'; // Left-pointing magnifying glass (🔍)
|
||||||
break;
|
break;
|
||||||
case 'log':
|
case 'log':
|
||||||
default:
|
default:
|
||||||
// Default textColor and icon are already set
|
// Default textColor and icon are already set
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={index} flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text color={textColor}>{icon} </Text>
|
<Text color={textColor}>{icon} </Text>
|
||||||
<Text color={textColor} wrap="wrap">
|
<Text color={textColor} wrap="wrap">
|
||||||
{msg.content}
|
{msg.content}
|
||||||
{msg.count && msg.count > 1 && (
|
{msg.count && msg.count > 1 && (
|
||||||
<Text color={theme.text.secondary}> (x{msg.count})</Text>
|
<Text color={theme.text.secondary}> (x{msg.count})</Text>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
</MaxSizedBox>
|
keyExtractor={(item, index) => `${item.content}-${index}`}
|
||||||
|
estimatedItemHeight={estimatedItemHeight}
|
||||||
|
hasFocus={hasFocus}
|
||||||
|
initialScrollIndex={Number.MAX_SAFE_INTEGER}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</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 { ExitWarning } from '../components/ExitWarning.js';
|
||||||
import { useUIState } from '../contexts/UIStateContext.js';
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
|
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
|
||||||
|
import { useSettings } from '../contexts/SettingsContext.js';
|
||||||
|
|
||||||
export const DefaultAppLayout: React.FC = () => {
|
export const DefaultAppLayout: React.FC = () => {
|
||||||
const uiState = useUIState();
|
const uiState = useUIState();
|
||||||
const { rootUiRef, terminalHeight } = uiState;
|
const { rootUiRef, terminalHeight } = uiState;
|
||||||
|
const settings = useSettings();
|
||||||
useFlickerDetector(rootUiRef, terminalHeight);
|
useFlickerDetector(rootUiRef, terminalHeight);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,6 +26,12 @@ export const DefaultAppLayout: React.FC = () => {
|
|||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width={uiState.mainAreaWidth}
|
width={uiState.mainAreaWidth}
|
||||||
ref={uiState.rootUiRef}
|
ref={uiState.rootUiRef}
|
||||||
|
height={
|
||||||
|
settings.merged.ui?.useAlternateBuffer ? terminalHeight - 1 : undefined
|
||||||
|
}
|
||||||
|
flexShrink={0}
|
||||||
|
flexGrow={0}
|
||||||
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<MainContent />
|
<MainContent />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user