mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-16 14:53:19 -07:00
Checkpoint VirtualizedListClick
This commit is contained in:
Generated
+5
-5
@@ -11,7 +11,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@7.0.1-beta.0",
|
||||
"ink": "npm:@jrichman/ink@7.0.1-beta.4",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
@@ -10020,9 +10020,9 @@
|
||||
},
|
||||
"node_modules/ink": {
|
||||
"name": "@jrichman/ink",
|
||||
"version": "7.0.1-beta.0",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.0.1-beta.0.tgz",
|
||||
"integrity": "sha512-vRzRMXNspq92OgikN2RTC0cpyErUn/OCQ97cAWikMEcy/dd8MZ/5SLisyZPL9t8Qt0LLIlY2WOB0cLkCj18qtQ==",
|
||||
"version": "7.0.1-beta.4",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.0.1-beta.4.tgz",
|
||||
"integrity": "sha512-gAcZpOu06y5ElVmyRj2KkHTWOq0ffvXqNjjy4WFVUD+8B0HFwg+ndVWHU+1VF/L9sFXS3jL1QdcSuqK4ss0nAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -18228,7 +18228,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@7.0.1-beta.0",
|
||||
"ink": "npm:@jrichman/ink@7.0.1-beta.4",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
+2
-2
@@ -73,7 +73,7 @@
|
||||
"pre-commit": "node scripts/pre-commit.js"
|
||||
},
|
||||
"overrides": {
|
||||
"ink": "npm:@jrichman/ink@7.0.1-beta.0",
|
||||
"ink": "npm:@jrichman/ink@7.0.1-beta.4",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -143,7 +143,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@7.0.1-beta.0",
|
||||
"ink": "npm:@jrichman/ink@7.0.1-beta.4",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
`MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the
|
||||
element is available, avoiding potential rendering timing issues.
|
||||
- Avoid prop drilling when at all possible.
|
||||
- **StaticRender**: Unlike Ink's native `<Static>` (which is printed above the
|
||||
application layout and takes no space in the flex container), the custom
|
||||
`<StaticRender>` component preserves its layout and _does_ take up its
|
||||
measured height in the active flex container.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@7.0.1-beta.0",
|
||||
"ink": "npm:@jrichman/ink@7.0.1-beta.4",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -394,18 +394,18 @@ const instances: InkInstance[] = [];
|
||||
export const render = async (
|
||||
tree: React.ReactElement,
|
||||
terminalWidth?: number,
|
||||
terminalHeight?: number,
|
||||
): Promise<
|
||||
Omit<RenderInstance, 'capturedOverflowState' | 'capturedOverflowActions'>
|
||||
> => {
|
||||
const cols = terminalWidth ?? 100;
|
||||
// We use 1000 rows to avoid windows with incorrect snapshots if a correct
|
||||
// value was used (e.g. 40 rows). The alternatives to make things worse are
|
||||
// windows unfortunately with odd duplicate content in the backbuffer
|
||||
// which does not match actual behavior in xterm.js on windows.
|
||||
const rows = 1000;
|
||||
// value was used (e.g. 40 rows).
|
||||
const rows = terminalHeight ?? 1000;
|
||||
const terminal = new Terminal({
|
||||
cols,
|
||||
rows,
|
||||
scrollback: 10000,
|
||||
allowProposedApi: true,
|
||||
convertEol: true,
|
||||
});
|
||||
@@ -627,6 +627,7 @@ export const renderWithProviders = async (
|
||||
quotaState: providedQuotaState,
|
||||
inputState: providedInputState,
|
||||
width,
|
||||
height,
|
||||
mouseEventsEnabled = false,
|
||||
config,
|
||||
uiActions,
|
||||
@@ -640,6 +641,7 @@ export const renderWithProviders = async (
|
||||
quotaState?: Partial<QuotaState>;
|
||||
inputState?: Partial<InputState>;
|
||||
width?: number;
|
||||
height?: number;
|
||||
mouseEventsEnabled?: boolean;
|
||||
config?: Config;
|
||||
uiActions?: Partial<UIActions>;
|
||||
@@ -813,6 +815,7 @@ export const renderWithProviders = async (
|
||||
const renderResult = await render(
|
||||
wrapWithProviders(component),
|
||||
terminalWidth,
|
||||
height,
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -205,19 +205,27 @@ export const MainContent = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
{ type: 'header' as const },
|
||||
...augmentedHistory.map((data, index) => ({
|
||||
const headerItem = useMemo(() => ({ type: 'header' as const }), []);
|
||||
|
||||
const historyVirtualizedItems = useMemo(
|
||||
() =>
|
||||
augmentedHistory.map((data, index) => ({
|
||||
type: 'history' as const,
|
||||
item: data.item,
|
||||
element: historyItems[index],
|
||||
})),
|
||||
{ type: 'pending' as const },
|
||||
],
|
||||
[augmentedHistory, historyItems],
|
||||
);
|
||||
|
||||
const virtualizedData = useMemo(
|
||||
() => [
|
||||
headerItem,
|
||||
...historyVirtualizedItems,
|
||||
{ type: 'pending' as const, pendingHistoryItems },
|
||||
],
|
||||
[headerItem, historyVirtualizedItems, pendingHistoryItems],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: (typeof virtualizedData)[number] }) => {
|
||||
if (item.type === 'header') {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
SerializableConfirmationDetails,
|
||||
ToolResultDisplay,
|
||||
} from '../../types.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
|
||||
describe('DenseToolMessage', () => {
|
||||
const defaultProps = {
|
||||
@@ -563,6 +564,71 @@ describe('DenseToolMessage', () => {
|
||||
// Verify it shows the diff when expanded
|
||||
expect(lastFrame()).toContain('new line');
|
||||
});
|
||||
|
||||
it('toggles expansion when header is clicked', async () => {
|
||||
const toggleExpansion = vi.fn();
|
||||
const toggleItem = vi.fn();
|
||||
let registeredCallback: (() => void) | undefined;
|
||||
|
||||
const MockVirtualizedListWrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const itemKey = 'item-1';
|
||||
const mockListContext = {
|
||||
toggleItem,
|
||||
registerClickCallback: vi.fn((key, id, cb) => {
|
||||
if (key === itemKey && id === 'toggle-call-1') {
|
||||
registeredCallback = cb;
|
||||
}
|
||||
}),
|
||||
unregisterClickCallback: vi.fn(),
|
||||
registerInteractivity: vi.fn(),
|
||||
setItemState: vi.fn(),
|
||||
getItemState: vi.fn(),
|
||||
isItemToggled: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
|
||||
return (
|
||||
<VirtualizedListContext.Provider
|
||||
value={
|
||||
mockListContext as unknown as React.ContextType<
|
||||
typeof VirtualizedListContext
|
||||
>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const { waitUntilReady } = await renderWithProviders(
|
||||
<MockVirtualizedListWrapper>
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
callId="call-1"
|
||||
itemKey="item-1"
|
||||
/>
|
||||
</MockVirtualizedListWrapper>,
|
||||
{
|
||||
toolActions: {
|
||||
toggleExpansion,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => expect(registeredCallback).toBeDefined());
|
||||
|
||||
// Trigger the registered callback manually (simulating VirtualizedList behavior)
|
||||
if (registeredCallback) {
|
||||
registeredCallback();
|
||||
}
|
||||
|
||||
expect(toggleItem).toHaveBeenCalledWith('item-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual Regression', () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo, useContext } from 'react';
|
||||
import { useMemo, useContext, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
CoreToolCallStatus,
|
||||
@@ -39,6 +39,7 @@ import { colorizeCode } from '../../utils/CodeColorizer.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { getFileExtension } from '../../utils/fileUtils.js';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
|
||||
|
||||
const PAYLOAD_MARGIN_LEFT = 6;
|
||||
const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols)
|
||||
@@ -47,6 +48,7 @@ const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER;
|
||||
|
||||
interface DenseToolMessageProps extends IndividualToolCallDisplay {
|
||||
itemKey?: string;
|
||||
groupKey?: string;
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
@@ -262,6 +264,7 @@ function getGenericSuccessData(
|
||||
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const {
|
||||
itemKey,
|
||||
groupKey,
|
||||
callId,
|
||||
name,
|
||||
status,
|
||||
@@ -275,7 +278,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
|
||||
const settings = useSettings();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const { isExpanded: isExpandedInContext } = useToolActions();
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
const virtualizedListContext = useContext(VirtualizedListContext);
|
||||
|
||||
// Determine expansion state based on list context or fallback to tool actions
|
||||
@@ -286,6 +289,20 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
return isExpandedInContext ? isExpandedInContext(callId) : false;
|
||||
}, [itemKey, virtualizedListContext, isExpandedInContext, callId]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (itemKey && virtualizedListContext?.toggleItem) {
|
||||
virtualizedListContext.toggleItem(itemKey);
|
||||
} else if (toggleExpansion) {
|
||||
toggleExpansion(callId);
|
||||
}
|
||||
}, [itemKey, virtualizedListContext, toggleExpansion, callId]);
|
||||
|
||||
const clickableProps = useVirtualizedListClick(
|
||||
groupKey ?? itemKey,
|
||||
`toggle-${callId}`,
|
||||
handleToggle,
|
||||
);
|
||||
|
||||
// Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
|
||||
const diff = useMemo((): FileDiff | undefined => {
|
||||
if (isFileDiff(resultDisplay)) return resultDisplay;
|
||||
@@ -432,7 +449,12 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginLeft={2} flexDirection="row" flexWrap="wrap">
|
||||
<Box
|
||||
ref={clickableProps.ref}
|
||||
marginLeft={2}
|
||||
flexDirection="row"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Box flexDirection="row" flexShrink={1}>
|
||||
<ToolStatusIndicator status={status} name={name} />
|
||||
<Box maxWidth={25} flexShrink={0} flexGrow={0}>
|
||||
@@ -467,7 +489,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
borderColor={theme.border.default}
|
||||
borderDimColor={true}
|
||||
maxWidth={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
PAYLOAD_MAX_WIDTH + PAYLOAD_BORDER_CHROME_WIDTH,
|
||||
terminalWidth - PAYLOAD_MARGIN_LEFT,
|
||||
)}
|
||||
>
|
||||
@@ -478,13 +500,7 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
hasFocus={false}
|
||||
width={Math.min(
|
||||
PAYLOAD_MAX_WIDTH,
|
||||
terminalWidth -
|
||||
PAYLOAD_MARGIN_LEFT -
|
||||
PAYLOAD_BORDER_CHROME_WIDTH -
|
||||
PAYLOAD_SCROLL_GUTTER,
|
||||
)}
|
||||
width="100%"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { VirtualizedList } from '../shared/VirtualizedList.js';
|
||||
import { DenseToolMessage } from './DenseToolMessage.js';
|
||||
import { Box } from 'ink';
|
||||
import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { createMockSettings } from '../../../test-utils/settings.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('DenseToolMessage Interactivity in VirtualizedList', () => {
|
||||
const keyExtractor = (item: { id: string }) => item.id;
|
||||
|
||||
it('toggles expansion when header is clicked in a VirtualizedList', async () => {
|
||||
const data = [{ id: '1' }];
|
||||
const diffResult = {
|
||||
fileName: 'test.ts',
|
||||
filePath: 'test.ts',
|
||||
fileDiff: '--- test.ts\n+++ test.ts\n@@ -1,1 +1,1 @@\n-old\n+new',
|
||||
diffStat: { model_added_lines: 1, model_removed_lines: 1 },
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
// We need to monitor if toggleItem is called on the list context
|
||||
// Actually, VirtualizedList handles its own state.
|
||||
// We can verify that it renders the payload after click.
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(
|
||||
<Box height={20} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={() => (
|
||||
<DenseToolMessage
|
||||
callId="call-1"
|
||||
itemKey="1-tool-call-1"
|
||||
groupKey="1"
|
||||
name="edit"
|
||||
status={CoreToolCallStatus.Success}
|
||||
resultDisplay={
|
||||
diffResult as unknown as React.ComponentProps<
|
||||
typeof DenseToolMessage
|
||||
>['resultDisplay']
|
||||
}
|
||||
terminalWidth={80}
|
||||
description="test"
|
||||
confirmationDetails={undefined}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
mouseEventsEnabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Initially it should be collapsed (no payload shown because of alternate buffer mode)
|
||||
expect(lastFrame()).toContain('edit');
|
||||
expect(lastFrame()).toContain('test.ts');
|
||||
expect(lastFrame()).not.toContain('new');
|
||||
|
||||
// Click on the first line (the header), avoiding the left margin
|
||||
await simulateClick(10, 1);
|
||||
|
||||
// Now it should be expanded and show the diff payload
|
||||
await waitFor(() => expect(lastFrame()).toContain('new'), {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('wakes up static DenseToolMessage and toggles on click', async () => {
|
||||
const data = [{ id: '1' }];
|
||||
const diffResult = {
|
||||
fileName: 'test.ts',
|
||||
filePath: 'test.ts',
|
||||
fileDiff: '--- test.ts\n+++ test.ts\n@@ -1,1 +1,1 @@\n-old\n+new',
|
||||
diffStat: { model_added_lines: 1, model_removed_lines: 1 },
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(
|
||||
<Box height={20} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={() => (
|
||||
<DenseToolMessage
|
||||
callId="call-1"
|
||||
itemKey="1-tool-call-1"
|
||||
groupKey="1"
|
||||
name="edit"
|
||||
status={CoreToolCallStatus.Success}
|
||||
resultDisplay={
|
||||
diffResult as unknown as React.ComponentProps<
|
||||
typeof DenseToolMessage
|
||||
>['resultDisplay']
|
||||
}
|
||||
terminalWidth={80}
|
||||
description="test"
|
||||
confirmationDetails={undefined}
|
||||
/>
|
||||
)}
|
||||
isStaticItem={() => true} // Force static rendering
|
||||
/>
|
||||
</Box>,
|
||||
{
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
mouseEventsEnabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Static item should still show the header
|
||||
expect(lastFrame()).toContain('edit');
|
||||
expect(lastFrame()).not.toContain('new');
|
||||
|
||||
// Click to wake up and toggle
|
||||
await simulateClick(10, 1);
|
||||
|
||||
// Should wake up and expand
|
||||
await waitFor(() => expect(lastFrame()).toContain('new'), {
|
||||
timeout: 5000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -440,10 +440,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
const commonProps = {
|
||||
...tool,
|
||||
itemKey: uniqueItemKey,
|
||||
groupKey: itemKey,
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
terminalWidth: contentWidth,
|
||||
emphasis: 'medium' as const,
|
||||
isFirst: isCompact ? false : isFirstProp,
|
||||
isFirst: isFirstProp,
|
||||
borderColor,
|
||||
borderDimColor,
|
||||
isExpandable,
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
CoreToolCallStatus,
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { VirtualizedListContext } from '../shared/VirtualizedList.js';
|
||||
import { Box, type DOMElement, getBoundingBox } from 'ink';
|
||||
import { useMouse } from '../../contexts/MouseContext.js';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type React from 'react';
|
||||
|
||||
describe('<TopicMessage />', () => {
|
||||
const baseArgs = {
|
||||
@@ -30,21 +35,83 @@ describe('<TopicMessage />', () => {
|
||||
isExpanded?: (callId: string) => boolean;
|
||||
toggleExpansion?: (callId: string) => void;
|
||||
},
|
||||
) =>
|
||||
renderWithProviders(
|
||||
<TopicMessage
|
||||
args={args}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={height}
|
||||
callId="test-topic"
|
||||
name={UPDATE_TOPIC_TOOL_NAME}
|
||||
description="Updating topic"
|
||||
status={CoreToolCallStatus.Success}
|
||||
confirmationDetails={undefined}
|
||||
resultDisplay={undefined}
|
||||
/>,
|
||||
virtualizedListProps?: {
|
||||
itemKey?: string;
|
||||
},
|
||||
) => {
|
||||
const defaultItemKey = virtualizedListProps?.itemKey || 'test-topic-key';
|
||||
|
||||
const MockVirtualizedListWrapper: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
const callbacks = useRef(new Map<string, () => void>());
|
||||
|
||||
const mockListContext = {
|
||||
registerInteractivity: vi.fn(),
|
||||
setItemState: vi.fn(),
|
||||
getItemState: vi.fn(),
|
||||
isItemToggled: vi.fn().mockReturnValue(false),
|
||||
toggleItem: vi.fn(),
|
||||
registerClickCallback: vi.fn((key, id, cb) => {
|
||||
if (key === defaultItemKey) callbacks.current.set(id, cb);
|
||||
}),
|
||||
unregisterClickCallback: vi.fn((key, id) => {
|
||||
if (key === defaultItemKey) callbacks.current.delete(id);
|
||||
}),
|
||||
};
|
||||
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
const handleMouse = useCallback(
|
||||
(event: { name: string; col: number; row: number }) => {
|
||||
if (event.name === 'left-press' && containerRef.current) {
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height: elHeight,
|
||||
} = getBoundingBox(containerRef.current);
|
||||
const mouseX = event.col - 1;
|
||||
const mouseY = event.row - 1;
|
||||
if (
|
||||
mouseX >= x &&
|
||||
mouseX < x + width &&
|
||||
mouseY >= y &&
|
||||
mouseY < y + elHeight
|
||||
) {
|
||||
const cb = callbacks.current.get('toggle');
|
||||
if (cb) cb();
|
||||
}
|
||||
}
|
||||
},
|
||||
[callbacks],
|
||||
);
|
||||
useMouse(handleMouse, { isActive: true });
|
||||
|
||||
return (
|
||||
<VirtualizedListContext.Provider value={mockListContext}>
|
||||
<Box ref={containerRef}>{children}</Box>
|
||||
</VirtualizedListContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
return renderWithProviders(
|
||||
<MockVirtualizedListWrapper>
|
||||
<TopicMessage
|
||||
args={args}
|
||||
itemKey={defaultItemKey}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={height}
|
||||
callId="test-topic"
|
||||
name={UPDATE_TOPIC_TOOL_NAME}
|
||||
description="Updating topic"
|
||||
status={CoreToolCallStatus.Success}
|
||||
confirmationDetails={undefined}
|
||||
resultDisplay={undefined}
|
||||
/>
|
||||
</MockVirtualizedListWrapper>,
|
||||
{ toolActions, mouseEventsEnabled: true },
|
||||
);
|
||||
};
|
||||
|
||||
it('renders title and intent by default (collapsed)', async () => {
|
||||
const { lastFrame } = await renderTopic(baseArgs, 40);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useId, useRef, useCallback } from 'react';
|
||||
import { Box, Text, type DOMElement } from 'ink';
|
||||
import { useEffect, useId, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
@@ -18,12 +18,13 @@ import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { useToolActions } from '../../contexts/ToolActionsContext.js';
|
||||
import { useMouseClick } from '../../hooks/useMouseClick.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
|
||||
|
||||
interface TopicMessageProps extends IndividualToolCallDisplay {
|
||||
terminalWidth: number;
|
||||
availableTerminalHeight?: number;
|
||||
isExpandable?: boolean;
|
||||
itemKey?: string;
|
||||
}
|
||||
|
||||
export const isTopicTool = (name: string): boolean =>
|
||||
@@ -34,6 +35,7 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
args,
|
||||
availableTerminalHeight,
|
||||
isExpandable = true,
|
||||
itemKey,
|
||||
}) => {
|
||||
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
|
||||
|
||||
@@ -47,7 +49,6 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
const overflowActions = useOverflowActions();
|
||||
const uniqueId = useId();
|
||||
const overflowId = `topic-${uniqueId}`;
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
const rawTitle = args?.[TOPIC_PARAM_TITLE];
|
||||
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
|
||||
@@ -75,9 +76,14 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
}
|
||||
}, [toggleExpansion, hasExtraSummary, callId]);
|
||||
|
||||
useMouseClick(containerRef, handleToggle, {
|
||||
isActive: isExpandable && hasExtraSummary,
|
||||
});
|
||||
const clickableProps = useVirtualizedListClick(
|
||||
itemKey,
|
||||
'toggle',
|
||||
handleToggle,
|
||||
{
|
||||
isActive: isExpandable && hasExtraSummary,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Only register if there is more content (summary) and it's currently hidden
|
||||
@@ -95,7 +101,7 @@ export const TopicMessage: React.FC<TopicMessageProps> = ({
|
||||
}, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]);
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} flexDirection="column" marginLeft={2}>
|
||||
<Box ref={clickableProps.ref} flexDirection="column" marginLeft={2}>
|
||||
<Box flexDirection="row" flexWrap="wrap">
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
{title || 'Topic'}
|
||||
|
||||
@@ -345,10 +345,6 @@ function FixedVirtualizedList<T>(
|
||||
prevScrollTop.current = rawDerivedActualScrollTop;
|
||||
prevContainerHeight.current = scrollableContainerHeight;
|
||||
|
||||
const scrollTop = currentIsStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: derivedActualScrollTop;
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.floor(derivedActualScrollTop / itemHeight) - 1,
|
||||
@@ -362,16 +358,31 @@ function FixedVirtualizedList<T>(
|
||||
Math.ceil((derivedActualScrollTop + viewHeightForEndIndex) / itemHeight),
|
||||
);
|
||||
|
||||
const culledHeight = useMemo(() => {
|
||||
if (
|
||||
overflowToBackbuffer &&
|
||||
typeof maxScrollbackLength === 'number' &&
|
||||
maxScrollbackLength > 0
|
||||
) {
|
||||
// Keep maxScrollbackLength items before the viewport.
|
||||
// We add 1 to startIndex to account for the 1-item overscan it includes.
|
||||
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
|
||||
return targetIndex * itemHeight;
|
||||
}
|
||||
return 0;
|
||||
}, [overflowToBackbuffer, maxScrollbackLength, startIndex, itemHeight]);
|
||||
|
||||
const scrollTop = currentIsStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: Math.max(0, derivedActualScrollTop - culledHeight);
|
||||
|
||||
const renderRangeStart = (() => {
|
||||
if (renderStatic) return 0;
|
||||
if (overflowToBackbuffer) {
|
||||
if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) {
|
||||
const targetOffset = Math.max(
|
||||
0,
|
||||
derivedActualScrollTop - maxScrollbackLength,
|
||||
);
|
||||
const index = Math.floor(targetOffset / itemHeight);
|
||||
return Math.max(0, index - 1);
|
||||
// Render from the culled boundary.
|
||||
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
|
||||
return targetIndex;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -380,7 +391,10 @@ function FixedVirtualizedList<T>(
|
||||
|
||||
const renderRangeEnd = renderStatic ? maxEndIndex : endIndex;
|
||||
|
||||
const topSpacerHeight = renderRangeStart * itemHeight;
|
||||
const topSpacerHeight = Math.max(
|
||||
0,
|
||||
renderRangeStart * itemHeight - culledHeight,
|
||||
);
|
||||
const bottomSpacerHeight = renderStatic
|
||||
? 0
|
||||
: totalHeight - (renderRangeEnd + 1) * itemHeight;
|
||||
@@ -446,7 +460,11 @@ function FixedVirtualizedList<T>(
|
||||
);
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const effectiveTotalHeight = totalHeight - culledHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
effectiveTotalHeight - scrollableContainerHeight,
|
||||
);
|
||||
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
@@ -458,7 +476,7 @@ function FixedVirtualizedList<T>(
|
||||
}
|
||||
} else {
|
||||
setIsStickingToBottom(false);
|
||||
const newScrollTop = Math.max(0, offset);
|
||||
const newScrollTop = Math.max(0, offset + culledHeight);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
|
||||
}
|
||||
@@ -530,10 +548,17 @@ function FixedVirtualizedList<T>(
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => {
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const effectiveTotalHeight = totalHeight - culledHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
effectiveTotalHeight - scrollableContainerHeight,
|
||||
);
|
||||
return {
|
||||
scrollTop: Math.min(getScrollTop(), maxScroll),
|
||||
scrollHeight: totalHeight,
|
||||
scrollTop: Math.min(
|
||||
Math.max(0, getScrollTop() - culledHeight),
|
||||
maxScroll,
|
||||
),
|
||||
scrollHeight: effectiveTotalHeight,
|
||||
innerHeight: scrollableContainerHeight,
|
||||
};
|
||||
},
|
||||
@@ -547,6 +572,7 @@ function FixedVirtualizedList<T>(
|
||||
getScrollTop,
|
||||
setPendingScrollTop,
|
||||
itemHeight,
|
||||
culledHeight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -554,7 +580,11 @@ function FixedVirtualizedList<T>(
|
||||
<Box
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollTop={
|
||||
isStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: Math.max(0, getScrollTop() - culledHeight)
|
||||
}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import type { VirtualizedListRef } from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createRef } from 'react';
|
||||
|
||||
describe('<VirtualizedList /> backbuffer regression', () => {
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
it('provides a sufficient history buffer regardless of height estimation', async () => {
|
||||
// 1000 items, each 1 line high.
|
||||
const data = Array.from(
|
||||
{ length: 1000 },
|
||||
(_, i) => `Item ${String(i).padStart(3, '0')}`,
|
||||
);
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
|
||||
const { waitUntilReady, unmount } = await render(
|
||||
<Box height={50} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={data}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 10}
|
||||
initialScrollIndex={999}
|
||||
overflowToBackbuffer={true}
|
||||
renderStatic={true}
|
||||
maxScrollbackLength={150}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
try {
|
||||
const state = ref.current?.getScrollState();
|
||||
// Viewport is 50, backbuffer is 150.
|
||||
// Total scrollHeight should be AT LEAST 200 lines.
|
||||
// Since our fix is item-based, and items are 1 line high, it should be
|
||||
// exactly or very close to 200.
|
||||
expect(state?.scrollHeight).toBeGreaterThanOrEqual(200);
|
||||
expect(state?.innerHeight).toBe(50);
|
||||
} finally {
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders as render } from '../../../test-utils/render.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import { Text, Box } from 'ink';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('<VirtualizedList /> fallback', () => {
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
it('uses default maxScrollbackLength of 1000 when not provided', async () => {
|
||||
const longData = Array.from({ length: 2000 }, (_, i) => `Item ${i}`);
|
||||
const renderedIndices = new Set<number>();
|
||||
const renderItem1px = ({
|
||||
item,
|
||||
index,
|
||||
}: {
|
||||
item: string;
|
||||
index: number;
|
||||
}) => {
|
||||
renderedIndices.add(index);
|
||||
return (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const { unmount } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={1999}
|
||||
overflowToBackbuffer={true}
|
||||
// maxScrollbackLength is NOT provided
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
// Viewport height is 10.
|
||||
// initialScrollIndex is 1999.
|
||||
// actualScrollTop = 2000 - 10 = 1990.
|
||||
// Default fallback maxScrollbackLength = 1000.
|
||||
// targetOffset = 1990 - 1000 = 990.
|
||||
// renderRangeStart should be around 989/990.
|
||||
// Items below 980 should NOT be rendered.
|
||||
// Items around 1000 SHOULD be rendered.
|
||||
|
||||
// Check viewport items are rendered
|
||||
expect(renderedIndices.has(1995)).toBe(true);
|
||||
expect(renderedIndices.has(1999)).toBe(true);
|
||||
|
||||
// Check items in maxScrollbackLength (1000) are rendered
|
||||
expect(renderedIndices.has(1000)).toBe(true);
|
||||
expect(renderedIndices.has(1100)).toBe(true);
|
||||
|
||||
// Check items beyond maxScrollbackLength are NOT rendered
|
||||
expect(renderedIndices.has(0)).toBe(false);
|
||||
expect(renderedIndices.has(500)).toBe(false);
|
||||
expect(renderedIndices.has(900)).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -377,6 +377,46 @@ describe('<VirtualizedList />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('crops the document height when maxScrollbackLength is exceeded', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const { unmount, waitUntilReady } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
|
||||
// Viewport height is 10.
|
||||
// maxScrollbackLength = 10.
|
||||
// Total expected scrollHeight = 10 + 10 = 20.
|
||||
const state = ref.current?.getScrollState();
|
||||
expect(state?.scrollHeight).toBe(20);
|
||||
|
||||
// The top of the projected document (offset 0) should correspond to absolute offset 80.
|
||||
// getAnchorForScrollTop(80) will return index 90 because it's near the bottom and uses a bottom anchor.
|
||||
await act(async () => {
|
||||
ref.current?.scrollTo(0);
|
||||
});
|
||||
expect(ref.current?.getScrollIndex()).toBe(90);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not forget item heights when items are prepended', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const data = ['Item 1', 'Item 2'];
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
StaticRender,
|
||||
getBoundingBox,
|
||||
getScrollTop as getInkScrollTop,
|
||||
useApp,
|
||||
} from 'ink';
|
||||
import {
|
||||
useMouse,
|
||||
@@ -38,6 +39,11 @@ import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export interface ClickableArea {
|
||||
id: string;
|
||||
box: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
export interface VirtualizedListContextValue {
|
||||
registerInteractivity: (
|
||||
itemKey: string,
|
||||
@@ -47,6 +53,12 @@ export interface VirtualizedListContextValue {
|
||||
getItemState: (itemKey: string, stateKey: string) => unknown;
|
||||
isItemToggled: (itemKey: string) => boolean;
|
||||
toggleItem: (itemKey: string) => void;
|
||||
registerClickCallback: (
|
||||
itemKey: string,
|
||||
areaId: string,
|
||||
callback: () => void,
|
||||
) => void;
|
||||
unregisterClickCallback: (itemKey: string, areaId: string) => void;
|
||||
}
|
||||
|
||||
export const VirtualizedListContext =
|
||||
@@ -108,6 +120,60 @@ function findLastIndex<T>(
|
||||
return -1;
|
||||
}
|
||||
|
||||
const extractClickableAreas = (rootNode: DOMElement): ClickableArea[] => {
|
||||
const rootBox = getBoundingBox(rootNode);
|
||||
const results: ClickableArea[] = [];
|
||||
|
||||
const traverse = (current: DOMElement) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const currentHack = current as unknown as {
|
||||
attributes?: Record<string, unknown>;
|
||||
yogaNode?: {
|
||||
getComputedWidth: () => number;
|
||||
getComputedHeight: () => number;
|
||||
};
|
||||
};
|
||||
const attributes = currentHack.attributes;
|
||||
|
||||
const clickableId =
|
||||
attributes?.['data-clickable'] ||
|
||||
attributes?.['id'] ||
|
||||
attributes?.['name'] ||
|
||||
attributes?.['clickableId'] ||
|
||||
attributes?.['nodeType'];
|
||||
|
||||
if (clickableId && typeof clickableId === 'string') {
|
||||
const childBox = getBoundingBox(current);
|
||||
|
||||
// If getBoundingBox returns null/0 for dimensions, try to look at internal layout if available
|
||||
const width = childBox.width ?? currentHack.yogaNode?.getComputedWidth();
|
||||
const height =
|
||||
childBox.height ?? currentHack.yogaNode?.getComputedHeight();
|
||||
|
||||
results.push({
|
||||
id: clickableId,
|
||||
box: {
|
||||
x: (childBox.x ?? 0) - (rootBox.x ?? 0),
|
||||
y: (childBox.y ?? 0) - (rootBox.y ?? 0),
|
||||
width: width ?? 0,
|
||||
height: height ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const child of current.childNodes || []) {
|
||||
if (child.nodeName && child.nodeName !== '#text') {
|
||||
// Ensure it's a DOMElement
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
traverse(child as any as DOMElement);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(rootNode);
|
||||
return results;
|
||||
};
|
||||
|
||||
const VirtualizedListItem = memo(
|
||||
({
|
||||
content,
|
||||
@@ -128,7 +194,14 @@ const VirtualizedListItem = memo(
|
||||
);
|
||||
|
||||
return (
|
||||
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
ref={itemRef}
|
||||
// @ts-expect-error custom attribute for testing
|
||||
nodeType="item-root"
|
||||
>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
@@ -169,9 +242,13 @@ function VirtualizedList<T>(
|
||||
overflowToBackbuffer,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
maxScrollbackLength,
|
||||
maxScrollbackLength: maxScrollbackLengthProp,
|
||||
} = props;
|
||||
|
||||
const app = useApp();
|
||||
const maxScrollbackLength =
|
||||
maxScrollbackLengthProp ?? app.options.maxScrollbackLength ?? 1000;
|
||||
|
||||
const [scrollAnchor, setScrollAnchor] = useState<{
|
||||
index: number;
|
||||
offset: number;
|
||||
@@ -229,6 +306,8 @@ function VirtualizedList<T>(
|
||||
);
|
||||
const itemStates = useRef(new Map<string, Map<string, unknown>>());
|
||||
const [toggledKeys, setToggledKeys] = useState(() => new Set<string>());
|
||||
const toggledKeysRef = useRef(toggledKeys);
|
||||
toggledKeysRef.current = toggledKeys;
|
||||
const [temporarilyInteractiveIndexes, setTemporarilyInteractiveIndexes] =
|
||||
useState(() => new Set<number>());
|
||||
const renderedAsStatic = useRef<boolean[]>([]);
|
||||
@@ -238,6 +317,11 @@ function VirtualizedList<T>(
|
||||
event: MouseEvent;
|
||||
} | null>(null);
|
||||
|
||||
const itemClickableAreas = useRef<Map<string, ClickableArea[]>>(new Map());
|
||||
const clickCallbacks = useRef<Map<string, Map<string, () => void>>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const virtualizedListContextValue = useMemo<VirtualizedListContextValue>(
|
||||
() => ({
|
||||
registerInteractivity: (itemKey, options) => {
|
||||
@@ -265,6 +349,23 @@ function VirtualizedList<T>(
|
||||
return next;
|
||||
});
|
||||
},
|
||||
registerClickCallback: (itemKey, areaId, callback) => {
|
||||
let itemMap = clickCallbacks.current.get(itemKey);
|
||||
if (!itemMap) {
|
||||
itemMap = new Map();
|
||||
clickCallbacks.current.set(itemKey, itemMap);
|
||||
}
|
||||
itemMap.set(areaId, callback);
|
||||
},
|
||||
unregisterClickCallback: (itemKey, areaId) => {
|
||||
const itemMap = clickCallbacks.current.get(itemKey);
|
||||
if (itemMap) {
|
||||
itemMap.delete(areaId);
|
||||
if (itemMap.size === 0) {
|
||||
clickCallbacks.current.delete(itemKey);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
[toggledKeys],
|
||||
);
|
||||
@@ -285,7 +386,13 @@ function VirtualizedList<T>(
|
||||
|
||||
const onStaticRender = useCallback(
|
||||
(index: number, key: string, node: DOMElement) => {
|
||||
const height = Math.round(getBoundingBox(node).height);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const currentHack = node as unknown as {
|
||||
yogaNode?: {
|
||||
getComputedHeight: () => number;
|
||||
};
|
||||
};
|
||||
const height = Math.round(currentHack.yogaNode?.getComputedHeight() ?? 0);
|
||||
if (
|
||||
height > 0 &&
|
||||
(state.current.measuredHeights[index] !== height ||
|
||||
@@ -295,6 +402,26 @@ function VirtualizedList<T>(
|
||||
state.current.measuredKeys[index] = key;
|
||||
setMeasurementVersion((v) => v + 1);
|
||||
}
|
||||
|
||||
if (itemClickableAreas.current.has(key)) {
|
||||
// If we already have areas for this item, don't re-extract.
|
||||
// This is especially important for static items because children might
|
||||
// have null dimensions in some environments (like tests) or might be
|
||||
// cleared from the DOM after caching.
|
||||
return;
|
||||
}
|
||||
|
||||
const areas = extractClickableAreas(node);
|
||||
if (areas.length > 0) {
|
||||
// In some test environments, dimensions might be null/0.
|
||||
// We only overwrite if we get valid dimensions or if we don't have areas yet.
|
||||
const hasValidDimensions = areas.some(
|
||||
(a) => a.box.width > 0 || a.box.height > 0,
|
||||
);
|
||||
if (hasValidDimensions || !itemClickableAreas.current.has(key)) {
|
||||
itemClickableAreas.current.set(key, areas);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -324,6 +451,14 @@ function VirtualizedList<T>(
|
||||
state.current.measuredKeys[index] = key;
|
||||
changed = true;
|
||||
}
|
||||
if (height > 0) {
|
||||
const areas = extractClickableAreas(entry.target);
|
||||
if (areas.length > 0) {
|
||||
itemClickableAreas.current.set(key, areas);
|
||||
} else {
|
||||
itemClickableAreas.current.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
@@ -497,9 +632,38 @@ function VirtualizedList<T>(
|
||||
estimatedItemHeight,
|
||||
]);
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
|
||||
);
|
||||
const viewHeightForEndIndex =
|
||||
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
|
||||
const endIndexOffset = offsets.findIndex(
|
||||
(offset) => offset > actualScrollTop + viewHeightForEndIndex,
|
||||
);
|
||||
const endIndex =
|
||||
endIndexOffset === -1
|
||||
? data.length - 1
|
||||
: Math.min(data.length - 1, endIndexOffset);
|
||||
|
||||
const culledHeight = useMemo(() => {
|
||||
if (
|
||||
overflowToBackbuffer &&
|
||||
typeof maxScrollbackLength === 'number' &&
|
||||
maxScrollbackLength > 0
|
||||
) {
|
||||
// Keep maxScrollbackLength items before the viewport to satisfy the backbuffer budget.
|
||||
// We use items as a proxy for lines to be robust against estimation errors.
|
||||
// We add 1 to startIndex to account for the 1-item overscan it includes.
|
||||
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
|
||||
return offsets[targetIndex] ?? 0;
|
||||
}
|
||||
return 0;
|
||||
}, [overflowToBackbuffer, maxScrollbackLength, startIndex, offsets]);
|
||||
|
||||
const scrollTop = isStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: actualScrollTop;
|
||||
: actualScrollTop - culledHeight;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (state.current.prevDataLength === -1) {
|
||||
@@ -660,20 +824,6 @@ function VirtualizedList<T>(
|
||||
props.targetScrollIndex,
|
||||
]);
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
|
||||
);
|
||||
const viewHeightForEndIndex =
|
||||
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
|
||||
const endIndexOffset = offsets.findIndex(
|
||||
(offset) => offset > actualScrollTop + viewHeightForEndIndex,
|
||||
);
|
||||
const endIndex =
|
||||
endIndexOffset === -1
|
||||
? data.length - 1
|
||||
: Math.min(data.length - 1, endIndexOffset);
|
||||
|
||||
useEffect(() => {
|
||||
setTemporarilyInteractiveIndexes((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
@@ -692,25 +842,17 @@ function VirtualizedList<T>(
|
||||
const renderRangeStart = useMemo(() => {
|
||||
if (overflowToBackbuffer) {
|
||||
if (typeof maxScrollbackLength === 'number' && maxScrollbackLength > 0) {
|
||||
const targetOffset = Math.max(0, actualScrollTop - maxScrollbackLength);
|
||||
const index = findLastIndex(
|
||||
offsets,
|
||||
(offset) => offset <= targetOffset,
|
||||
);
|
||||
return Math.max(0, index - 1);
|
||||
// We render everything from the culledHeight boundary to ensure the
|
||||
// backbuffer is fully populated.
|
||||
const targetIndex = Math.max(0, startIndex + 1 - maxScrollbackLength);
|
||||
return targetIndex;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return startIndex;
|
||||
}, [
|
||||
overflowToBackbuffer,
|
||||
maxScrollbackLength,
|
||||
actualScrollTop,
|
||||
offsets,
|
||||
startIndex,
|
||||
]);
|
||||
}, [overflowToBackbuffer, maxScrollbackLength, startIndex]);
|
||||
|
||||
const topSpacerHeight = offsets[renderRangeStart];
|
||||
const topSpacerHeight = Math.max(0, offsets[renderRangeStart] - culledHeight);
|
||||
|
||||
let renderRangeEnd = endIndex;
|
||||
if (maxRenderRangeEnd.current > endIndex) {
|
||||
@@ -735,8 +877,10 @@ function VirtualizedList<T>(
|
||||
}
|
||||
maxRenderRangeEnd.current = renderRangeEnd;
|
||||
|
||||
const bottomSpacerHeight =
|
||||
totalHeight - (offsets[renderRangeEnd + 1] ?? totalHeight);
|
||||
const bottomSpacerHeight = Math.max(
|
||||
0,
|
||||
totalHeight - (offsets[renderRangeEnd + 1] ?? totalHeight),
|
||||
);
|
||||
|
||||
// Always evaluate shouldBeStatic, width, etc. if we have a known width from the prop.
|
||||
// If containerHeight or containerWidth is 0 we defer rendering unless a static render or defined width overrides.
|
||||
@@ -744,10 +888,23 @@ function VirtualizedList<T>(
|
||||
// BUT the initial render MUST render *something* with a width if width prop is provided to avoid layout shifts.
|
||||
// We MUST wait for containerHeight > 0 before rendering, especially if renderStatic is true.
|
||||
// If containerHeight is 0, we will misclassify items as isOutsideViewport and permanently print them to StaticRender!
|
||||
const itemCache = useRef(
|
||||
new Map<
|
||||
string,
|
||||
{
|
||||
item: T;
|
||||
element: React.ReactElement;
|
||||
shouldBeStatic: boolean;
|
||||
width: number | string | undefined;
|
||||
containerWidth: number;
|
||||
index: number;
|
||||
isToggled: boolean;
|
||||
}
|
||||
>(),
|
||||
);
|
||||
|
||||
const isReady =
|
||||
containerHeight > 0 ||
|
||||
process.env['NODE_ENV'] === 'test' ||
|
||||
(width !== undefined && typeof width === 'number');
|
||||
containerHeight > 0 || (width !== undefined && typeof width === 'number');
|
||||
|
||||
const renderedItems = useMemo(() => {
|
||||
if (!isReady) {
|
||||
@@ -768,31 +925,57 @@ function VirtualizedList<T>(
|
||||
const shouldBeStatic = isStaticByDefault && !isTemporarilyInteractive;
|
||||
renderedAsStatic.current[i] = shouldBeStatic;
|
||||
|
||||
const content = renderItem({ item, index: i });
|
||||
const key = keyExtractor(item, i);
|
||||
const cached = itemCache.current.get(key);
|
||||
|
||||
if (shouldBeStatic) {
|
||||
items.push(
|
||||
<StaticRender
|
||||
key={`${key}-static-${typeof width === 'number' ? width : containerWidth}`}
|
||||
width={typeof width === 'number' ? width : containerWidth}
|
||||
onRender={(node: DOMElement) => onStaticRender(i, key, node)}
|
||||
>
|
||||
{() => content}
|
||||
</StaticRender>,
|
||||
);
|
||||
const isToggled = toggledKeys.has(key);
|
||||
|
||||
let contentElement: React.ReactElement;
|
||||
if (
|
||||
cached &&
|
||||
cached.item === item &&
|
||||
cached.shouldBeStatic === shouldBeStatic &&
|
||||
cached.width === width &&
|
||||
cached.containerWidth === containerWidth &&
|
||||
cached.index === i &&
|
||||
cached.isToggled === isToggled
|
||||
) {
|
||||
contentElement = cached.element;
|
||||
} else {
|
||||
items.push(
|
||||
<VirtualizedListItem
|
||||
key={key}
|
||||
itemKey={key}
|
||||
content={content}
|
||||
index={i}
|
||||
onSetRef={onSetRef}
|
||||
/>,
|
||||
);
|
||||
if (shouldBeStatic) {
|
||||
contentElement = (
|
||||
<StaticRender
|
||||
key={`${key}-static-${typeof width === 'number' ? width : containerWidth}`}
|
||||
width={typeof width === 'number' ? width : containerWidth}
|
||||
onRender={(node: DOMElement) => onStaticRender(i, key, node)}
|
||||
>
|
||||
{() => renderItem({ item, index: i })}
|
||||
</StaticRender>
|
||||
);
|
||||
} else {
|
||||
contentElement = (
|
||||
<VirtualizedListItem
|
||||
key={key}
|
||||
itemKey={key}
|
||||
content={renderItem({ item, index: i })}
|
||||
index={i}
|
||||
onSetRef={onSetRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
itemCache.current.set(key, {
|
||||
item,
|
||||
element: contentElement,
|
||||
shouldBeStatic,
|
||||
width,
|
||||
containerWidth,
|
||||
index: i,
|
||||
isToggled,
|
||||
});
|
||||
}
|
||||
|
||||
items.push(contentElement);
|
||||
|
||||
if (
|
||||
!renderStatic &&
|
||||
state.current.measuredKeys[i] !== key &&
|
||||
@@ -811,6 +994,30 @@ function VirtualizedList<T>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup cache to avoid memory leaks
|
||||
if (
|
||||
itemCache.current.size >
|
||||
Math.max(100, (renderRangeEnd - renderRangeStart + 1) * 3)
|
||||
) {
|
||||
const keysToKeep = new Set<string>();
|
||||
for (
|
||||
let i = Math.max(0, renderRangeStart - 50);
|
||||
i <= Math.min(data.length - 1, renderRangeEnd + 50);
|
||||
i++
|
||||
) {
|
||||
const item = data[i];
|
||||
if (item) {
|
||||
keysToKeep.add(keyExtractor(item, i));
|
||||
}
|
||||
}
|
||||
for (const key of itemCache.current.keys()) {
|
||||
if (!keysToKeep.has(key)) {
|
||||
itemCache.current.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
isReady,
|
||||
@@ -829,6 +1036,7 @@ function VirtualizedList<T>(
|
||||
estimatedItemHeight,
|
||||
temporarilyInteractiveIndexes,
|
||||
onStaticRender,
|
||||
toggledKeys,
|
||||
]);
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
@@ -873,31 +1081,88 @@ function VirtualizedList<T>(
|
||||
) {
|
||||
// getScrollTop() might return MAX_SAFE_INTEGER if stuck to bottom.
|
||||
// We need the true rendered layout scroll top which ink exposes directly via getScrollTop.
|
||||
const trueScrollTop = getInkScrollTop(state.current.container);
|
||||
const trueScrollTop =
|
||||
getInkScrollTop(state.current.container) + culledHeight;
|
||||
const absoluteY = trueScrollTop + relativeY;
|
||||
|
||||
const index = findLastIndex(offsets, (offset) => offset <= absoluteY);
|
||||
|
||||
// DEBUG LOGGING
|
||||
debugLogger.log(
|
||||
`[Mouse] event=${event.name} index=${index} static=${renderedAsStatic.current[index]}`,
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
const item = data[index];
|
||||
if (item) {
|
||||
const itemKey = keyExtractor(item, index);
|
||||
const options = interactiveKeys.current.get(itemKey);
|
||||
|
||||
// Determine if the click was exactly on the first line of the item
|
||||
const itemStartY = offsets[index] ?? 0;
|
||||
const isFirstLineClick = isClick && absoluteY === itemStartY;
|
||||
|
||||
// Hit-test against explicitly defined clickable areas inside the item
|
||||
if (isClick && itemClickableAreas.current.has(itemKey)) {
|
||||
const mouseRelativeY = absoluteY - itemStartY;
|
||||
const areas = itemClickableAreas.current.get(itemKey) ?? [];
|
||||
|
||||
for (const area of areas) {
|
||||
if (
|
||||
relativeX >= area.box.x &&
|
||||
relativeX < area.box.x + area.box.width &&
|
||||
mouseRelativeY >= area.box.y &&
|
||||
mouseRelativeY < area.box.y + area.box.height
|
||||
) {
|
||||
debugLogger.log(
|
||||
`[Mouse] Clicked inside tagged area: ${area.id} in itemKey: ${itemKey}`,
|
||||
);
|
||||
|
||||
if (renderedAsStatic.current[index]) {
|
||||
debugLogger.log(
|
||||
`[Mouse] Waking up static item index=${index} due to click on area=${area.id}`,
|
||||
);
|
||||
setTemporarilyInteractiveIndexes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(index);
|
||||
return next;
|
||||
});
|
||||
setPendingReplayEvent({ index, event });
|
||||
// Also toggle immediately if it was a first-line click
|
||||
if (isFirstLineClick) {
|
||||
virtualizedListContextValue.toggleItem(itemKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = clickCallbacks.current
|
||||
.get(itemKey)
|
||||
?.get(area.id);
|
||||
if (callback) {
|
||||
debugLogger.log(
|
||||
`[Mouse] Dispatching click callback for area=${area.id} in itemKey=${itemKey}`,
|
||||
);
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`[Mouse] itemKey=${itemKey} options=${JSON.stringify(options)}`,
|
||||
);
|
||||
if (options) {
|
||||
// Determine if the click was exactly on the first line of the item
|
||||
const itemStartY = offsets[index];
|
||||
const isFirstLineClick = isClick && absoluteY === itemStartY;
|
||||
|
||||
if (isFirstLineClick && options.click) {
|
||||
if (renderedAsStatic.current[index]) {
|
||||
debugLogger.log(
|
||||
`[Mouse] Waking up static item index=${index} due to first-line click`,
|
||||
);
|
||||
setTemporarilyInteractiveIndexes((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(index);
|
||||
return next;
|
||||
});
|
||||
setPendingReplayEvent({ index, event });
|
||||
return;
|
||||
}
|
||||
debugLogger.log(
|
||||
`[Mouse] First line click detected. Toggling itemKey=${itemKey}.`,
|
||||
);
|
||||
@@ -920,7 +1185,7 @@ function VirtualizedList<T>(
|
||||
}
|
||||
}
|
||||
},
|
||||
[offsets, data, keyExtractor, virtualizedListContextValue],
|
||||
[offsets, data, keyExtractor, virtualizedListContextValue, culledHeight],
|
||||
);
|
||||
|
||||
useMouse(handleMouse, { isActive: true });
|
||||
@@ -951,7 +1216,11 @@ function VirtualizedList<T>(
|
||||
);
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const effectiveTotalHeight = totalHeight - culledHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
effectiveTotalHeight - scrollableContainerHeight,
|
||||
);
|
||||
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
@@ -963,7 +1232,7 @@ function VirtualizedList<T>(
|
||||
}
|
||||
} else {
|
||||
setIsStickingToBottom(false);
|
||||
const newScrollTop = Math.max(0, offset);
|
||||
const newScrollTop = Math.max(0, offset + culledHeight);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(
|
||||
getAnchorForScrollTop(
|
||||
@@ -1058,10 +1327,17 @@ function VirtualizedList<T>(
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => {
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const effectiveTotalHeight = totalHeight - culledHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
effectiveTotalHeight - scrollableContainerHeight,
|
||||
);
|
||||
return {
|
||||
scrollTop: Math.min(getScrollTop(), maxScroll),
|
||||
scrollHeight: totalHeight,
|
||||
scrollTop: Math.min(
|
||||
Math.max(0, getScrollTop() - culledHeight),
|
||||
maxScroll,
|
||||
),
|
||||
scrollHeight: effectiveTotalHeight,
|
||||
innerHeight: scrollableContainerHeight,
|
||||
};
|
||||
},
|
||||
@@ -1075,6 +1351,7 @@ function VirtualizedList<T>(
|
||||
scrollableContainerHeight,
|
||||
getScrollTop,
|
||||
setPendingScrollTop,
|
||||
culledHeight,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1084,7 +1361,11 @@ function VirtualizedList<T>(
|
||||
ref={containerRefCallback}
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollTop={
|
||||
isStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: Math.max(0, getScrollTop() - culledHeight)
|
||||
}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { VirtualizedList } from './VirtualizedList.js';
|
||||
import { useVirtualizedListClick } from '../../hooks/useVirtualizedListClick.js';
|
||||
import { Box, Text } from 'ink';
|
||||
import { useState } from 'react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('VirtualizedList Interactivity', () => {
|
||||
const keyExtractor = (item: { id: string }) => item.id;
|
||||
|
||||
const InteractiveItem = ({
|
||||
id,
|
||||
onToggle,
|
||||
}: {
|
||||
id: string;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const { ref } = useVirtualizedListClick(id, 'toggle', onToggle);
|
||||
return (
|
||||
<Box height={1} width={80} ref={ref}>
|
||||
<Text>Item {id}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
it('triggers callback when tagged area is clicked', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const data = [{ id: '1' }];
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(
|
||||
<Box height={10} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={({ item }) => (
|
||||
<InteractiveItem id={item.id} onToggle={onToggle} />
|
||||
)}
|
||||
/>
|
||||
</Box>,
|
||||
{ mouseEventsEnabled: true },
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
|
||||
// Simulate click on the first line (Item 1)
|
||||
// VirtualizedList is at (0,0) and Item 1 is at (0,0) relative to list.
|
||||
// simulateClick expects absolute coordinates.
|
||||
// In renderWithProviders, the wrapper Box is at (0,0)?
|
||||
// Actually getBoundingBox(state.current.container) in VirtualizedList will give absolute coords.
|
||||
await simulateClick(1, 1);
|
||||
|
||||
await waitFor(() => expect(onToggle).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('wakes up static item and triggers callback on click', async () => {
|
||||
const onToggle = vi.fn();
|
||||
const data = [{ id: '1' }];
|
||||
|
||||
const TestComponent = () => {
|
||||
const [isStatic, setIsStatic] = useState(false);
|
||||
return (
|
||||
<Box height={10} width={80}>
|
||||
<VirtualizedList
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1}
|
||||
renderItem={({ item }) => (
|
||||
<InteractiveItem id={item.id} onToggle={onToggle} />
|
||||
)}
|
||||
isStaticItem={() => isStatic}
|
||||
/>
|
||||
<Box
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
setTimeout(() => setIsStatic(true), 100);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const { simulateClick, waitUntilReady, lastFrame } =
|
||||
await renderWithProviders(<TestComponent />, {
|
||||
mouseEventsEnabled: true,
|
||||
});
|
||||
|
||||
await waitUntilReady();
|
||||
// Wait for the transition to static to happen and be recorded
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(lastFrame()).toContain('Item 1');
|
||||
|
||||
// Click to wake up and trigger
|
||||
await simulateClick(1, 1);
|
||||
|
||||
await waitFor(() => expect(onToggle).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useContext, useEffect, useCallback } from 'react';
|
||||
import { VirtualizedListContext } from '../components/shared/VirtualizedList.js';
|
||||
import { type DOMElement } from 'ink';
|
||||
|
||||
/**
|
||||
* A hook to register a clickable area within a VirtualizedList item.
|
||||
* This works seamlessly with both static and dynamic rendering.
|
||||
*
|
||||
* @param itemKey The unique key for the list item.
|
||||
* @param areaId A unique identifier for this clickable area within the list item.
|
||||
* @param callback The function to execute when the area is clicked.
|
||||
* @param options Configuration options.
|
||||
* @returns Props to spread onto the clickable component.
|
||||
*/
|
||||
export const useVirtualizedListClick = (
|
||||
itemKey: string | undefined,
|
||||
areaId: string,
|
||||
callback: () => void,
|
||||
options: { isActive?: boolean } = {},
|
||||
) => {
|
||||
const { isActive = true } = options;
|
||||
const listContext = useContext(VirtualizedListContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive && listContext && itemKey) {
|
||||
listContext.registerClickCallback(itemKey, areaId, callback);
|
||||
return () => {
|
||||
listContext.unregisterClickCallback(itemKey, areaId);
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [isActive, listContext, itemKey, areaId, callback]);
|
||||
|
||||
const ref = useCallback(
|
||||
(el: DOMElement | null) => {
|
||||
if (el) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const elHack = el as unknown as { attributes: Record<string, unknown> };
|
||||
if (!elHack.attributes) {
|
||||
elHack.attributes = {};
|
||||
}
|
||||
elHack.attributes['data-clickable'] = areaId;
|
||||
}
|
||||
},
|
||||
[areaId],
|
||||
);
|
||||
|
||||
return { ref };
|
||||
};
|
||||
Reference in New Issue
Block a user