mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-17 07:13:07 -07:00
Optimize VirtualizedList
Checkpoint optimizing virtualized list Fixes for fallback rendering where terminalBuffer=false Change terminalBuffer false back to the default while we fix performance with very large chats. Checkpoint changes to virtualized list. Fix virtualized list NO commit Update ink version. Fix UI snapshot mismatch in MainContent tests and VirtualizedList computation Checkpoint.
This commit is contained in:
@@ -80,6 +80,7 @@ they appear in the UI.
|
||||
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `false` |
|
||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||
| Max Scrollback Length | `ui.maxScrollbackLength` | Maximum number of lines to keep in the terminal scrollback buffer. | `1000` |
|
||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, all, or off. | `"off"` |
|
||||
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
|
||||
|
||||
@@ -376,6 +376,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.maxScrollbackLength`** (number):
|
||||
- **Description:** Maximum number of lines to keep in the terminal scrollback
|
||||
buffer.
|
||||
- **Default:** `1000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`ui.showSpinner`** (boolean):
|
||||
- **Description:** Show the spinner during operations.
|
||||
- **Default:** `true`
|
||||
|
||||
Generated
+5
-5
@@ -11,7 +11,7 @@
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.0.0-beta.2",
|
||||
"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": "6.6.9",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
|
||||
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
|
||||
"version": "7.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-7.0.0-beta.2.tgz",
|
||||
"integrity": "sha512-cc222452y0FK1gl7/p+veunoABGL1LAfF57RfDYCGYcTxxogN3IaM/KbkaY0pKQLngLBj8mz7GyOabq+O4DY2A==",
|
||||
"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@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.0.0-beta.2",
|
||||
"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@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.0.0-beta.2",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"cliui": {
|
||||
"wrap-ansi": "7.0.0"
|
||||
@@ -143,7 +143,7 @@
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.0.0-beta.2",
|
||||
"latest-version": "^9.0.0",
|
||||
"node-fetch-native": "^1.6.7",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"fzf": "^0.5.2",
|
||||
"glob": "^12.0.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ink": "npm:@jrichman/ink@6.6.9",
|
||||
"ink": "npm:@jrichman/ink@7.0.0-beta.2",
|
||||
"ink-gradient": "^3.0.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"latest-version": "^9.0.0",
|
||||
|
||||
@@ -816,6 +816,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled.',
|
||||
showInDialog: true,
|
||||
},
|
||||
maxScrollbackLength: {
|
||||
type: 'number',
|
||||
label: 'Max Scrollback Length',
|
||||
category: 'UI',
|
||||
requiresRestart: true,
|
||||
default: 1000,
|
||||
description:
|
||||
'Maximum number of lines to keep in the terminal scrollback buffer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showSpinner: {
|
||||
type: 'boolean',
|
||||
label: 'Show Spinner',
|
||||
|
||||
@@ -155,8 +155,7 @@ export async function startInteractiveUI(
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
standardReactLayoutTiming:
|
||||
useAlternateBuffer || config.getUseTerminalBuffer(),
|
||||
standardReactLayoutTiming: false,
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
terminalBuffer: config.getUseTerminalBuffer(),
|
||||
@@ -167,6 +166,9 @@ export async function startInteractiveUI(
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
debugRainbow: settings.merged.ui.debugRainbow === true,
|
||||
|
||||
// @ts-expect-error Custom option in our fork of ink
|
||||
maxScrollbackLength: settings.merged.ui.maxScrollbackLength,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useIsScreenReaderEnabled } from 'ink';
|
||||
import { useUIState } from './contexts/UIStateContext.js';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
@@ -13,7 +14,7 @@ import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
|
||||
import { AlternateBufferQuittingDisplay } from './components/AlternateBufferQuittingDisplay.js';
|
||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||
|
||||
export const App = () => {
|
||||
export const App = React.memo(() => {
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isScreenReaderEnabled = useIsScreenReaderEnabled();
|
||||
@@ -35,4 +36,6 @@ export const App = () => {
|
||||
{isScreenReaderEnabled ? <ScreenReaderAppLayout /> : <DefaultAppLayout />}
|
||||
</StreamingContext.Provider>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
App.displayName = 'App';
|
||||
|
||||
@@ -1556,6 +1556,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
|
||||
);
|
||||
|
||||
// In terminalBuffer mode, we return terminalHeight - 1 to prevent frequent
|
||||
// invalidation of UIState. This value is correct for the few cases where a
|
||||
// fixed terminal height must be respected.
|
||||
const uiStateAvailableTerminalHeight = config.getUseTerminalBuffer()
|
||||
? terminalHeight - 1
|
||||
: availableTerminalHeight;
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION),
|
||||
terminalHeight: Math.max(
|
||||
@@ -2506,7 +2513,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlDPressedOnce: ctrlDPressCount >= 1,
|
||||
shortcutsHelpVisible,
|
||||
cleanUiDetailsVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
@@ -2520,7 +2526,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
availableTerminalHeight: uiStateAvailableTerminalHeight,
|
||||
stableControlsHeight,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
@@ -2619,7 +2625,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlDPressCount,
|
||||
shortcutsHelpVisible,
|
||||
cleanUiDetailsVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
currentTip,
|
||||
@@ -2632,7 +2637,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
allowPlanMode,
|
||||
contextFileNames,
|
||||
errorCount,
|
||||
availableTerminalHeight,
|
||||
uiStateAvailableTerminalHeight,
|
||||
stableControlsHeight,
|
||||
mainAreaWidth,
|
||||
staticAreaMaxItemHeight,
|
||||
|
||||
@@ -61,6 +61,15 @@ Tips for getting started:
|
||||
2. /help for more information
|
||||
3. Ask coding questions, edit code or run commands
|
||||
4. Be specific for the best results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Composer
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -143,6 +143,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
{uiState.isInputActive && (
|
||||
<InputPrompt
|
||||
maxAvailableWidth={terminalWidth}
|
||||
onSubmit={uiActions.handleFinalSubmit}
|
||||
setBannerVisible={uiActions.setBannerVisible}
|
||||
onClearScreen={uiActions.handleClearScreen}
|
||||
|
||||
@@ -432,6 +432,7 @@ describe('InputPrompt', () => {
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('');
|
||||
|
||||
props = {
|
||||
maxAvailableWidth: 80,
|
||||
onQueueMessage: vi.fn(),
|
||||
|
||||
buffer: mockBuffer,
|
||||
|
||||
@@ -20,9 +20,9 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
|
||||
import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
} from './shared/ScrollableList.js';
|
||||
FixedScrollableList,
|
||||
type FixedScrollableListRef,
|
||||
} from './shared/FixedScrollableList.js';
|
||||
import { ListeningIndicator } from './ListeningIndicator.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import {
|
||||
@@ -112,6 +112,7 @@ export type ScrollableItem =
|
||||
| { type: 'ghostLine'; ghostLine: string; index: number };
|
||||
|
||||
export interface InputPromptProps {
|
||||
maxAvailableWidth: number;
|
||||
onSubmit: (value: string) => void;
|
||||
onClearScreen: () => void;
|
||||
config: Config;
|
||||
@@ -200,6 +201,7 @@ export function tryTogglePasteExpansion(buffer: TextBuffer): boolean {
|
||||
}
|
||||
|
||||
export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
maxAvailableWidth,
|
||||
onSubmit,
|
||||
onClearScreen,
|
||||
config,
|
||||
@@ -283,7 +285,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const innerBoxRef = useRef<DOMElement>(null);
|
||||
const hasUserNavigatedSuggestions = useRef(false);
|
||||
const listRef = useRef<ScrollableListRef<ScrollableItem>>(null);
|
||||
const listRef = useRef<FixedScrollableListRef<ScrollableItem>>(null);
|
||||
|
||||
const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
|
||||
buffer,
|
||||
@@ -1855,22 +1857,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
height={Math.min(buffer.viewportHeight, scrollableData.length)}
|
||||
width="100%"
|
||||
>
|
||||
{config.getUseTerminalBuffer() ? (
|
||||
<ScrollableList
|
||||
{isAlternateBuffer ? (
|
||||
<FixedScrollableList
|
||||
ref={listRef}
|
||||
hasFocus={focus}
|
||||
data={scrollableData}
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={() => 1}
|
||||
fixedItemHeight={true}
|
||||
itemHeight={1}
|
||||
keyExtractor={(item) =>
|
||||
item.type === 'visualLine'
|
||||
? `line-${item.absoluteVisualIdx}`
|
||||
: `ghost-${item.index}`
|
||||
}
|
||||
width={inputWidth}
|
||||
width={maxAvailableWidth}
|
||||
backgroundColor={listBackgroundColor}
|
||||
containerHeight={Math.min(
|
||||
maxHeight={Math.min(
|
||||
buffer.viewportHeight,
|
||||
scrollableData.length,
|
||||
)}
|
||||
|
||||
@@ -358,6 +358,7 @@ describe('MainContent', () => {
|
||||
bannerVisible: false,
|
||||
copyModeEnabled: false,
|
||||
terminalWidth: 100,
|
||||
mouseMode: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -803,7 +804,6 @@ describe('MainContent', () => {
|
||||
expect(output).toContain('Planning execution');
|
||||
expect(output).toContain('Refining approach');
|
||||
expect(output).toMatchSnapshot();
|
||||
await expect(renderResult).toMatchSvgSnapshot();
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
|
||||
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { useInputState } from '../contexts/InputContext.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
@@ -37,6 +38,7 @@ export const MainContent = () => {
|
||||
const config = useConfig();
|
||||
const useTerminalBuffer = config.getUseTerminalBuffer();
|
||||
const isAlternateBuffer = config.getUseAlternateBuffer();
|
||||
const { copyModeEnabled } = useInputState();
|
||||
|
||||
const confirmingTool = useConfirmingTool();
|
||||
const showConfirmationQueue = confirmingTool !== null;
|
||||
@@ -234,7 +236,7 @@ export const MainContent = () => {
|
||||
[showHeaderDetails, version, pendingItems],
|
||||
);
|
||||
|
||||
const estimatedItemHeight = useCallback(() => 100, []);
|
||||
const estimatedItemHeight = useCallback(() => 10, []);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: (typeof virtualizedData)[number], _index: number) => {
|
||||
@@ -271,7 +273,7 @@ export const MainContent = () => {
|
||||
renderStatic={useTerminalBuffer}
|
||||
isStaticItem={useTerminalBuffer ? isStaticItem : undefined}
|
||||
overflowToBackbuffer={useTerminalBuffer && !isAlternateBuffer}
|
||||
scrollbar={mouseMode}
|
||||
scrollbar={mouseMode && !copyModeEnabled}
|
||||
/>
|
||||
// TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()}
|
||||
// as that will reduce the # of cases where we will have to clear the
|
||||
@@ -295,6 +297,7 @@ export const MainContent = () => {
|
||||
isStaticItem,
|
||||
mouseMode,
|
||||
isAlternateBuffer,
|
||||
copyModeEnabled,
|
||||
]);
|
||||
|
||||
if (!uiState.isConfigInitialized) {
|
||||
|
||||
@@ -213,24 +213,3 @@ AppHeader(full)
|
||||
│ refine the solution.
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`MainContent > renders multiple thinking messages sequentially correctly 2`] = `
|
||||
"ScrollableList
|
||||
AppHeader(full)
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
> Plan a solution
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
Thinking...
|
||||
│
|
||||
│ Initial analysis
|
||||
│ This is a multiple line paragraph for the first thinking message of how the
|
||||
│ model analyzes the problem.
|
||||
│
|
||||
│ Planning execution
|
||||
│ This a second multiple line paragraph for the second thinking message
|
||||
│ explaining the plan in detail so that it wraps around the terminal display.
|
||||
│
|
||||
│ Refining approach
|
||||
│ And finally a third multiple line paragraph for the third thinking message to
|
||||
│ refine the solution."
|
||||
`;
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { Scrollable } from '../shared/Scrollable.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { FixedScrollableList } from '../shared/FixedScrollableList.js';
|
||||
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
|
||||
@@ -213,12 +213,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const data = resultDisplay as AnsiOutput;
|
||||
|
||||
// Calculate list height: if not constrained, use full data length.
|
||||
// If constrained (e.g. alternate buffer), limit to available height
|
||||
// to ensure virtualization works and fits within the viewport.
|
||||
const listHeight = !constrainHeight
|
||||
? data.length
|
||||
: Math.min(data.length, limit);
|
||||
// In alternate buffer, always constrain to limit to ensure virtualization works and fits viewport.
|
||||
const listHeight = isAlternateBuffer
|
||||
? Math.min(data.length, limit)
|
||||
: !constrainHeight
|
||||
? data.length
|
||||
: Math.min(data.length, limit);
|
||||
|
||||
if (isAlternateBuffer) {
|
||||
const initialScrollIndex =
|
||||
@@ -226,13 +226,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||
<ScrollableList
|
||||
<FixedScrollableList
|
||||
width={childWidth}
|
||||
containerHeight={listHeight}
|
||||
maxHeight={listHeight}
|
||||
data={data}
|
||||
renderItem={renderVirtualizedAnsiLine}
|
||||
estimatedItemHeight={() => 1}
|
||||
fixedItemHeight={true}
|
||||
itemHeight={1}
|
||||
keyExtractor={keyExtractor}
|
||||
initialScrollIndex={initialScrollIndex}
|
||||
hasFocus={hasFocus}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import {
|
||||
FixedVirtualizedList,
|
||||
type FixedVirtualizedListRef,
|
||||
type FixedVirtualizedListProps,
|
||||
SCROLL_TO_ITEM_END,
|
||||
} from './FixedVirtualizedList.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';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
interface FixedScrollableListProps<T> extends FixedVirtualizedListProps<T> {
|
||||
hasFocus: boolean;
|
||||
width: number;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
targetScrollIndex?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
}
|
||||
|
||||
export type FixedScrollableListRef<T> = FixedVirtualizedListRef<T>;
|
||||
|
||||
function FixedScrollableList<T>(
|
||||
props: FixedScrollableListProps<T>,
|
||||
ref: React.Ref<FixedScrollableListRef<T>>,
|
||||
) {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const {
|
||||
hasFocus,
|
||||
width,
|
||||
maxHeight,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
} = props;
|
||||
const fixedVirtualizedListRef = useRef<FixedVirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollBy: (delta) => fixedVirtualizedListRef.current?.scrollBy(delta),
|
||||
scrollTo: (offset) => fixedVirtualizedListRef.current?.scrollTo(offset),
|
||||
scrollToEnd: () => fixedVirtualizedListRef.current?.scrollToEnd(),
|
||||
scrollToIndex: (params) =>
|
||||
fixedVirtualizedListRef.current?.scrollToIndex(params),
|
||||
scrollToItem: (params) =>
|
||||
fixedVirtualizedListRef.current?.scrollToItem(params),
|
||||
getScrollIndex: () =>
|
||||
fixedVirtualizedListRef.current?.getScrollIndex() ?? 0,
|
||||
getScrollState: () =>
|
||||
fixedVirtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const getScrollState = useCallback(
|
||||
() =>
|
||||
fixedVirtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollBy = useCallback((delta: number) => {
|
||||
fixedVirtualizedListRef.current?.scrollBy(delta);
|
||||
}, []);
|
||||
|
||||
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
|
||||
useAnimatedScrollbar(hasFocus, scrollBy);
|
||||
|
||||
const smoothScrollState = useRef<{
|
||||
active: boolean;
|
||||
start: number;
|
||||
from: number;
|
||||
to: number;
|
||||
duration: number;
|
||||
timer: NodeJS.Timeout | null;
|
||||
}>({ active: false, start: 0, from: 0, to: 0, duration: 0, timer: null });
|
||||
|
||||
const stopSmoothScroll = useCallback(() => {
|
||||
if (smoothScrollState.current.timer) {
|
||||
clearInterval(smoothScrollState.current.timer);
|
||||
smoothScrollState.current.timer = null;
|
||||
}
|
||||
smoothScrollState.current.active = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
|
||||
|
||||
const smoothScrollTo = useCallback(
|
||||
(
|
||||
targetScrollTop: number,
|
||||
duration: number = process.env['NODE_ENV'] === 'test' ? 0 : 200,
|
||||
) => {
|
||||
stopSmoothScroll();
|
||||
|
||||
const scrollState = fixedVirtualizedListRef.current?.getScrollState() ?? {
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
innerHeight: 0,
|
||||
};
|
||||
const {
|
||||
scrollTop: rawStartScrollTop,
|
||||
scrollHeight,
|
||||
innerHeight,
|
||||
} = scrollState;
|
||||
|
||||
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
|
||||
const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop);
|
||||
|
||||
let effectiveTarget = targetScrollTop;
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
effectiveTarget = maxScrollTop;
|
||||
}
|
||||
|
||||
const clampedTarget = Math.max(
|
||||
0,
|
||||
Math.min(maxScrollTop, effectiveTarget),
|
||||
);
|
||||
|
||||
if (duration === 0) {
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
|
||||
} else {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
|
||||
}
|
||||
flashScrollbar();
|
||||
return;
|
||||
}
|
||||
|
||||
smoothScrollState.current = {
|
||||
active: true,
|
||||
start: Date.now(),
|
||||
from: startScrollTop,
|
||||
to: clampedTarget,
|
||||
duration,
|
||||
timer: setInterval(() => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - smoothScrollState.current.start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-in-out
|
||||
const t = progress;
|
||||
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
||||
|
||||
const current =
|
||||
smoothScrollState.current.from +
|
||||
(smoothScrollState.current.to - smoothScrollState.current.from) *
|
||||
ease;
|
||||
|
||||
if (progress >= 1) {
|
||||
if (
|
||||
targetScrollTop === SCROLL_TO_ITEM_END ||
|
||||
targetScrollTop >= maxScrollTop
|
||||
) {
|
||||
fixedVirtualizedListRef.current?.scrollTo(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
} else {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Math.round(current));
|
||||
}
|
||||
stopSmoothScroll();
|
||||
flashScrollbar();
|
||||
} else {
|
||||
fixedVirtualizedListRef.current?.scrollTo(Math.round(current));
|
||||
}
|
||||
}, ANIMATION_FRAME_DURATION_MS),
|
||||
};
|
||||
},
|
||||
[stopSmoothScroll, flashScrollbar],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key: Key) => {
|
||||
if (keyMatchers[Command.SCROLL_UP](key)) {
|
||||
stopSmoothScroll();
|
||||
scrollByWithAnimation(-1);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SCROLL_DOWN](key)) {
|
||||
stopSmoothScroll();
|
||||
scrollByWithAnimation(1);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.PAGE_UP](key) ||
|
||||
keyMatchers[Command.PAGE_DOWN](key)
|
||||
) {
|
||||
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
|
||||
const scrollState = getScrollState();
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
scrollState.scrollHeight - scrollState.innerHeight,
|
||||
);
|
||||
const current = smoothScrollState.current.active
|
||||
? smoothScrollState.current.to
|
||||
: Math.min(scrollState.scrollTop, maxScroll);
|
||||
const innerHeight = scrollState.innerHeight;
|
||||
smoothScrollTo(current + direction * innerHeight);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SCROLL_HOME](key)) {
|
||||
smoothScrollTo(0);
|
||||
return true;
|
||||
} else if (keyMatchers[Command.SCROLL_END](key)) {
|
||||
smoothScrollTo(SCROLL_TO_ITEM_END);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ isActive: hasFocus },
|
||||
);
|
||||
|
||||
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
|
||||
|
||||
const scrollableEntry = useMemo(
|
||||
() => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
ref: containerRef as React.RefObject<DOMElement>,
|
||||
getScrollState,
|
||||
scrollBy: scrollByWithAnimation,
|
||||
scrollTo: smoothScrollTo,
|
||||
hasFocus: hasFocusCallback,
|
||||
flashScrollbar,
|
||||
}),
|
||||
[
|
||||
getScrollState,
|
||||
hasFocusCallback,
|
||||
flashScrollbar,
|
||||
scrollByWithAnimation,
|
||||
smoothScrollTo,
|
||||
],
|
||||
);
|
||||
|
||||
useScrollable(scrollableEntry, true);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
width={width}
|
||||
maxHeight={maxHeight}
|
||||
>
|
||||
<FixedVirtualizedList
|
||||
ref={fixedVirtualizedListRef}
|
||||
{...props}
|
||||
scrollbar={scrollbar}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
stableScrollback={stableScrollback}
|
||||
maxScrollbackLength={maxScrollbackLength}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const FixedScrollableListWithForwardRef = forwardRef(FixedScrollableList) as <
|
||||
T,
|
||||
>(
|
||||
props: FixedScrollableListProps<T> & {
|
||||
ref?: React.Ref<FixedScrollableListRef<T>>;
|
||||
},
|
||||
) => React.ReactElement;
|
||||
|
||||
export { FixedScrollableListWithForwardRef as FixedScrollableList };
|
||||
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useCallback,
|
||||
memo,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
|
||||
import { Box, StaticRender } from 'ink';
|
||||
|
||||
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export type FixedVirtualizedListProps<T> = {
|
||||
data: T[];
|
||||
renderItem: (info: { item: T; index: number }) => React.ReactElement;
|
||||
itemHeight: number;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
initialScrollIndex?: number;
|
||||
initialScrollOffsetInIndex?: number;
|
||||
targetScrollIndex?: number;
|
||||
backgroundColor?: string;
|
||||
scrollbarThumbColor?: string;
|
||||
renderStatic?: boolean;
|
||||
isStaticItem?: (item: T, index: number) => boolean;
|
||||
width: number;
|
||||
overflowToBackbuffer?: boolean;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
maxHeight: number;
|
||||
maxScrollbackLength?: number;
|
||||
};
|
||||
|
||||
export type FixedVirtualizedListRef<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;
|
||||
};
|
||||
};
|
||||
|
||||
const FixedVirtualizedListItem = memo(
|
||||
({
|
||||
content,
|
||||
shouldBeStatic,
|
||||
width,
|
||||
itemKey,
|
||||
}: {
|
||||
content: React.ReactElement;
|
||||
shouldBeStatic: boolean;
|
||||
width: number;
|
||||
itemKey: string;
|
||||
}) => (
|
||||
<Box width="100%" flexDirection="column" flexShrink={0}>
|
||||
{shouldBeStatic ? (
|
||||
<StaticRender width={width} key={itemKey + '-static-' + width}>
|
||||
{() => content}
|
||||
</StaticRender>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
);
|
||||
|
||||
FixedVirtualizedListItem.displayName = 'FixedVirtualizedListItem';
|
||||
|
||||
function FixedVirtualizedList<T>(
|
||||
props: FixedVirtualizedListProps<T>,
|
||||
ref: React.Ref<FixedVirtualizedListRef<T>>,
|
||||
) {
|
||||
const {
|
||||
data,
|
||||
renderItem,
|
||||
itemHeight,
|
||||
keyExtractor,
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
renderStatic,
|
||||
isStaticItem,
|
||||
width,
|
||||
overflowToBackbuffer,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
maxScrollbackLength,
|
||||
maxHeight,
|
||||
} = props;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof props.targetScrollIndex === 'number') {
|
||||
return {
|
||||
index: props.targetScrollIndex,
|
||||
offset: 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 totalHeight = data.length * itemHeight;
|
||||
const scrollableContainerHeight = maxHeight;
|
||||
const isInitialScrollSet = useRef(false);
|
||||
|
||||
const getAnchorForScrollTop = useCallback(
|
||||
(scrollTop: number): { index: number; offset: number } => {
|
||||
const index = Math.max(
|
||||
0,
|
||||
Math.min(data.length - 1, Math.floor(scrollTop / itemHeight)),
|
||||
);
|
||||
if (data.length === 0) {
|
||||
return { index: 0, offset: 0 };
|
||||
}
|
||||
return { index, offset: scrollTop - index * itemHeight };
|
||||
},
|
||||
[data.length, itemHeight],
|
||||
);
|
||||
|
||||
const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState(
|
||||
props.targetScrollIndex,
|
||||
);
|
||||
const prevDataLength = useRef(data.length);
|
||||
|
||||
if (
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
props.targetScrollIndex !== prevTargetScrollIndex &&
|
||||
data.length > 0) ||
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
prevDataLength.current === 0 &&
|
||||
data.length > 0)
|
||||
) {
|
||||
if (props.targetScrollIndex !== prevTargetScrollIndex) {
|
||||
setPrevTargetScrollIndex(props.targetScrollIndex);
|
||||
}
|
||||
prevDataLength.current = data.length;
|
||||
setIsStickingToBottom(false);
|
||||
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
|
||||
} else {
|
||||
prevDataLength.current = data.length;
|
||||
}
|
||||
|
||||
const rawStateActualScrollTop = (() => {
|
||||
const offset = scrollAnchor.index * itemHeight;
|
||||
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
||||
return offset + itemHeight - scrollableContainerHeight;
|
||||
}
|
||||
return offset + scrollAnchor.offset;
|
||||
})();
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const stateActualScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(maxScroll, rawStateActualScrollTop),
|
||||
);
|
||||
|
||||
const prevTotalHeight = useRef(totalHeight);
|
||||
const prevScrollTop = useRef(rawStateActualScrollTop);
|
||||
const prevContainerHeight = useRef(scrollableContainerHeight);
|
||||
|
||||
// Render-time state derivation to avoid useEffect for static rendering
|
||||
let currentScrollAnchor = scrollAnchor;
|
||||
let currentIsStickingToBottom = isStickingToBottom;
|
||||
|
||||
const contentPreviouslyFit =
|
||||
prevTotalHeight.current <= prevContainerHeight.current;
|
||||
const wasScrolledToBottomPixels =
|
||||
prevScrollTop.current >=
|
||||
prevTotalHeight.current - prevContainerHeight.current - 1;
|
||||
|
||||
// Crucial fix: we were previously only evaluating wasAtBottom against rawStateActualScrollTop *if* it was at bottom *last* frame.
|
||||
// But if the content just exceeded the container height, wasScrolledToBottomPixels is false, but contentPreviouslyFit is true.
|
||||
// If it previously fit, it implicitly means we should stick to the bottom if the new height exceeds the container.
|
||||
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
||||
|
||||
if (
|
||||
wasAtBottom &&
|
||||
(rawStateActualScrollTop >= prevScrollTop.current || contentPreviouslyFit)
|
||||
) {
|
||||
if (!currentIsStickingToBottom) {
|
||||
currentIsStickingToBottom = true;
|
||||
if (scrollAnchor === currentScrollAnchor) {
|
||||
// Avoid infinite loop if we already updated state
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listGrew = data.length > prevDataLength.current;
|
||||
const containerChanged =
|
||||
prevContainerHeight.current !== scrollableContainerHeight;
|
||||
const shouldAutoScroll = props.targetScrollIndex === undefined;
|
||||
|
||||
if (
|
||||
shouldAutoScroll &&
|
||||
((listGrew && (currentIsStickingToBottom || wasAtBottom)) ||
|
||||
(currentIsStickingToBottom && containerChanged))
|
||||
) {
|
||||
const newIndex = data.length > 0 ? data.length - 1 : 0;
|
||||
if (
|
||||
currentScrollAnchor.index !== newIndex ||
|
||||
currentScrollAnchor.offset !== SCROLL_TO_ITEM_END
|
||||
) {
|
||||
currentScrollAnchor = {
|
||||
index: newIndex,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
};
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
}
|
||||
if (!currentIsStickingToBottom) {
|
||||
currentIsStickingToBottom = true;
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
} else if (
|
||||
(currentScrollAnchor.index >= data.length ||
|
||||
stateActualScrollTop > totalHeight - scrollableContainerHeight) &&
|
||||
data.length > 0
|
||||
) {
|
||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const newAnchor = getAnchorForScrollTop(newScrollTop);
|
||||
if (
|
||||
currentScrollAnchor.index !== newAnchor.index ||
|
||||
currentScrollAnchor.offset !== newAnchor.offset
|
||||
) {
|
||||
currentScrollAnchor = newAnchor;
|
||||
setScrollAnchor(newAnchor);
|
||||
}
|
||||
} else if (data.length === 0) {
|
||||
if (currentScrollAnchor.index !== 0 || currentScrollAnchor.offset !== 0) {
|
||||
currentScrollAnchor = { index: 0, offset: 0 };
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
}
|
||||
}
|
||||
|
||||
// Initial scroll setup during render
|
||||
if (
|
||||
!isInitialScrollSet.current &&
|
||||
data.length > 0 &&
|
||||
totalHeight > 0 &&
|
||||
scrollableContainerHeight > 0
|
||||
) {
|
||||
if (props.targetScrollIndex !== undefined) {
|
||||
isInitialScrollSet.current = true;
|
||||
} else if (typeof initialScrollIndex === 'number') {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(initialScrollIndex >= data.length - 1 &&
|
||||
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
|
||||
|
||||
if (scrollToEnd) {
|
||||
currentScrollAnchor = {
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
};
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
currentIsStickingToBottom = true;
|
||||
setIsStickingToBottom(true);
|
||||
isInitialScrollSet.current = true;
|
||||
} else {
|
||||
const index = Math.max(
|
||||
0,
|
||||
Math.min(data.length - 1, initialScrollIndex),
|
||||
);
|
||||
const offset = initialScrollOffsetInIndex ?? 0;
|
||||
const newScrollTop = index * itemHeight + offset;
|
||||
|
||||
const clampedScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
|
||||
);
|
||||
|
||||
currentScrollAnchor = getAnchorForScrollTop(clampedScrollTop);
|
||||
setScrollAnchor(currentScrollAnchor);
|
||||
isInitialScrollSet.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// After all derived state updates, update refs for the next render
|
||||
prevDataLength.current = data.length;
|
||||
prevTotalHeight.current = totalHeight;
|
||||
|
||||
const rawDerivedActualScrollTop = (() => {
|
||||
const offset = currentScrollAnchor.index * itemHeight;
|
||||
if (currentScrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
||||
return offset + itemHeight - scrollableContainerHeight;
|
||||
}
|
||||
return offset + currentScrollAnchor.offset;
|
||||
})();
|
||||
const derivedActualScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(maxScroll, rawDerivedActualScrollTop),
|
||||
);
|
||||
|
||||
prevScrollTop.current = rawDerivedActualScrollTop;
|
||||
prevContainerHeight.current = scrollableContainerHeight;
|
||||
|
||||
const scrollTop = currentIsStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: derivedActualScrollTop;
|
||||
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.floor(derivedActualScrollTop / itemHeight) - 1,
|
||||
);
|
||||
const viewHeightForEndIndex =
|
||||
scrollableContainerHeight > 0 ? scrollableContainerHeight : 50;
|
||||
|
||||
const maxEndIndex = data.length - 1;
|
||||
const endIndex = Math.min(
|
||||
maxEndIndex,
|
||||
Math.ceil((derivedActualScrollTop + viewHeightForEndIndex) / itemHeight),
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return startIndex;
|
||||
})();
|
||||
|
||||
const renderRangeEnd = renderStatic ? maxEndIndex : endIndex;
|
||||
|
||||
const topSpacerHeight = renderRangeStart * itemHeight;
|
||||
const bottomSpacerHeight = renderStatic
|
||||
? 0
|
||||
: totalHeight - (renderRangeEnd + 1) * itemHeight;
|
||||
|
||||
const renderedItems = useMemo(() => {
|
||||
const items = [];
|
||||
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
|
||||
const item = data[i];
|
||||
if (item) {
|
||||
const isOutsideViewport = i < startIndex || i > endIndex;
|
||||
const shouldBeStatic =
|
||||
(renderStatic === true && isOutsideViewport) ||
|
||||
isStaticItem?.(item, i) === true;
|
||||
|
||||
const content = renderItem({ item, index: i });
|
||||
const key = keyExtractor(item, i);
|
||||
|
||||
items.push(
|
||||
<FixedVirtualizedListItem
|
||||
key={key}
|
||||
itemKey={key}
|
||||
content={content}
|
||||
shouldBeStatic={shouldBeStatic}
|
||||
width={width}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [
|
||||
renderRangeStart,
|
||||
renderRangeEnd,
|
||||
data,
|
||||
startIndex,
|
||||
endIndex,
|
||||
renderStatic,
|
||||
isStaticItem,
|
||||
renderItem,
|
||||
keyExtractor,
|
||||
width,
|
||||
]);
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
scrollBy: (delta: number) => {
|
||||
if (delta < 0) {
|
||||
setIsStickingToBottom(false);
|
||||
}
|
||||
const currentScrollTop = getScrollTop();
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const actualCurrent = Math.min(currentScrollTop, maxScroll);
|
||||
let newScrollTop = Math.max(0, actualCurrent + delta);
|
||||
if (newScrollTop >= maxScroll) {
|
||||
setIsStickingToBottom(true);
|
||||
newScrollTop = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(
|
||||
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll)),
|
||||
);
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
if (data.length > 0) {
|
||||
setScrollAnchor({
|
||||
index: data.length - 1,
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setIsStickingToBottom(false);
|
||||
const newScrollTop = Math.max(0, offset);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
|
||||
}
|
||||
},
|
||||
scrollToEnd: () => {
|
||||
setIsStickingToBottom(true);
|
||||
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
|
||||
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 = index * itemHeight;
|
||||
if (index >= 0 && index < data.length) {
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
totalHeight - scrollableContainerHeight,
|
||||
);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxScroll,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
|
||||
}
|
||||
},
|
||||
scrollToItem: ({
|
||||
item,
|
||||
viewOffset = 0,
|
||||
viewPosition = 0,
|
||||
}: {
|
||||
item: T;
|
||||
viewOffset?: number;
|
||||
viewPosition?: number;
|
||||
}) => {
|
||||
setIsStickingToBottom(false);
|
||||
const index = data.indexOf(item);
|
||||
if (index !== -1) {
|
||||
const offset = index * itemHeight;
|
||||
const maxScroll = Math.max(
|
||||
0,
|
||||
totalHeight - scrollableContainerHeight,
|
||||
);
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
maxScroll,
|
||||
offset - viewPosition * scrollableContainerHeight + viewOffset,
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop));
|
||||
}
|
||||
},
|
||||
getScrollIndex: () => scrollAnchor.index,
|
||||
getScrollState: () => {
|
||||
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
return {
|
||||
scrollTop: Math.min(getScrollTop(), maxScroll),
|
||||
scrollHeight: totalHeight,
|
||||
innerHeight: scrollableContainerHeight,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[
|
||||
scrollAnchor,
|
||||
totalHeight,
|
||||
getAnchorForScrollTop,
|
||||
data,
|
||||
scrollableContainerHeight,
|
||||
getScrollTop,
|
||||
setPendingScrollTop,
|
||||
itemHeight,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={1}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
<Box height={Math.max(0, bottomSpacerHeight)} flexShrink={0} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const FixedVirtualizedListWithForwardRef = forwardRef(FixedVirtualizedList) as <
|
||||
T,
|
||||
>(
|
||||
props: FixedVirtualizedListProps<T> & {
|
||||
ref?: React.Ref<FixedVirtualizedListRef<T>>;
|
||||
},
|
||||
) => React.ReactElement;
|
||||
|
||||
export { FixedVirtualizedListWithForwardRef as FixedVirtualizedList };
|
||||
|
||||
FixedVirtualizedList.displayName = 'FixedVirtualizedList';
|
||||
@@ -25,6 +25,7 @@ import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
@@ -33,9 +34,7 @@ interface ScrollableListProps<T> extends VirtualizedListProps<T> {
|
||||
width?: string | number;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
copyModeEnabled?: boolean;
|
||||
isStatic?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
targetScrollIndex?: number;
|
||||
containerHeight?: number;
|
||||
scrollbarThumbColor?: string;
|
||||
@@ -48,6 +47,8 @@ function ScrollableList<T>(
|
||||
ref: React.Ref<ScrollableListRef<T>>,
|
||||
) {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const settings = useSettings();
|
||||
const maxScrollbackLength = settings.merged.ui?.maxScrollbackLength;
|
||||
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
|
||||
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
|
||||
const containerRef = useRef<DOMElement>(null);
|
||||
@@ -265,6 +266,7 @@ function ScrollableList<T>(
|
||||
scrollbar={scrollbar}
|
||||
scrollbarThumbColor={scrollbarColor}
|
||||
stableScrollback={stableScrollback}
|
||||
maxScrollbackLength={maxScrollbackLength}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -170,7 +170,7 @@ describe('<VirtualizedList />', () => {
|
||||
(_, i) => `Item ${i}`,
|
||||
);
|
||||
|
||||
const { lastFrame, unmount } = await render(
|
||||
const { lastFrame, unmount, waitUntilReady } = await render(
|
||||
<Box height={20} width={100} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={veryLongData}
|
||||
@@ -184,6 +184,11 @@ describe('<VirtualizedList />', () => {
|
||||
</Box>,
|
||||
);
|
||||
|
||||
await waitUntilReady();
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(mountedCount).toBe(expectedMountedCount);
|
||||
expect(frame).toMatchSnapshot();
|
||||
@@ -316,32 +321,161 @@ describe('<VirtualizedList />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders correctly in copyModeEnabled when scrolled', async () => {
|
||||
it('culls items that exceed maxScrollbackLength when overflowToBackbuffer is true', async () => {
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
// Use copy mode
|
||||
const { lastFrame, unmount } = await render(
|
||||
<Box height={10} width={100}>
|
||||
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} borderStyle="round">
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={99}
|
||||
overflowToBackbuffer={true}
|
||||
maxScrollbackLength={10}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
// Viewport height is 10, total items = 100.
|
||||
// actualScrollTop = 92 (due to top/bottom borders taking 2 lines out of 10, inner height 8).
|
||||
// wait, if height is 10 with round border, inner height is 8.
|
||||
// actualScrollTop = 100 - 8 = 92.
|
||||
// maxScrollbackLength = 10.
|
||||
// targetOffset = 92 - 10 = 82.
|
||||
// So renderRangeStart should be 81 (or 82).
|
||||
// Items 0 to 80 should not be rendered!
|
||||
|
||||
// Check viewport items are rendered
|
||||
expect(renderedIndices.has(95)).toBe(true);
|
||||
expect(renderedIndices.has(99)).toBe(true);
|
||||
|
||||
// Check items in maxScrollbackLength are rendered
|
||||
expect(renderedIndices.has(85)).toBe(true);
|
||||
|
||||
// Check items beyond maxScrollbackLength are NOT rendered
|
||||
expect(renderedIndices.has(0)).toBe(false);
|
||||
expect(renderedIndices.has(50)).toBe(false);
|
||||
expect(renderedIndices.has(75)).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not forget item heights when items are prepended', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const data = ['Item 1', 'Item 2'];
|
||||
const { rerender, waitUntilReady, unmount } = await render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={data}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={50}
|
||||
copyModeEnabled={true}
|
||||
estimatedItemHeight={() => 1000}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
// Item 50 should be visible
|
||||
expect(lastFrame()).toContain('Item 50');
|
||||
// And surrounding items
|
||||
expect(lastFrame()).toContain('Item 59');
|
||||
// But far away items should not be (ensures we are actually scrolled)
|
||||
expect(lastFrame()).not.toContain('Item 0');
|
||||
await waitUntilReady();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// Item 1 and 2 measured. totalHeight = 2.
|
||||
expect(ref.current?.getScrollState().scrollHeight).toBe(2);
|
||||
|
||||
// Prepend Item 0
|
||||
const newData = ['Item 0', 'Item 1', 'Item 2'];
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={newData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1000}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
});
|
||||
// With the Map-based cache, Item 1 and 2 heights (1 each) should be preserved
|
||||
// even though their indices changed.
|
||||
// Item 0 is new and uses estimate 1000.
|
||||
// So totalHeight should be 1002 (before Item 0 is measured).
|
||||
// Note: It might already be 3 if Item 0 was measured immediately, but it
|
||||
// definitely shouldn't be 3000 (which it would be if Item 1 and 2 were forgotten).
|
||||
const scrollHeight = ref.current?.getScrollState().scrollHeight;
|
||||
expect(scrollHeight).toBeGreaterThan(0);
|
||||
expect(scrollHeight).toBeLessThan(3000);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ref.current?.getScrollState().scrollHeight).toBe(3);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('updates totalHeight correctly when estimated height differs from real height and scrolled up', async () => {
|
||||
const ref = createRef<VirtualizedListRef<string>>();
|
||||
const longData = Array.from({ length: 10 }, (_, i) => `Item ${i}`);
|
||||
const itemHeight = 1;
|
||||
const renderItem1px = ({ item }: { item: string }) => (
|
||||
<Box height={itemHeight}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
);
|
||||
const keyExtractor = (item: string) => item;
|
||||
|
||||
const { unmount } = await render(
|
||||
<Box height={5} width={100}>
|
||||
<VirtualizedList
|
||||
ref={ref}
|
||||
data={longData}
|
||||
renderItem={renderItem1px}
|
||||
keyExtractor={keyExtractor}
|
||||
estimatedItemHeight={() => 1000}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await act(async () => {
|
||||
ref.current?.scrollTo(i * 1000);
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10)); // allow React/Ink to process the scroll
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
ref.current?.scrollTo(0);
|
||||
});
|
||||
// Wait for the final scroll top to settle and height to be correct
|
||||
await waitFor(() => {
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(0);
|
||||
expect(ref.current?.getScrollState().scrollHeight).toBe(10);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useMemo,
|
||||
useCallback,
|
||||
memo,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
@@ -39,9 +40,9 @@ export type VirtualizedListProps<T> = {
|
||||
overflowToBackbuffer?: boolean;
|
||||
scrollbar?: boolean;
|
||||
stableScrollback?: boolean;
|
||||
copyModeEnabled?: boolean;
|
||||
fixedItemHeight?: boolean;
|
||||
containerHeight?: number;
|
||||
maxScrollbackLength?: number;
|
||||
};
|
||||
|
||||
export type VirtualizedListRef<T> = {
|
||||
@@ -78,47 +79,46 @@ function findLastIndex<T>(
|
||||
return -1;
|
||||
}
|
||||
|
||||
const MemoizedStaticItem = memo(
|
||||
({
|
||||
content,
|
||||
width,
|
||||
itemKey,
|
||||
}: {
|
||||
content: React.ReactElement;
|
||||
width: number;
|
||||
itemKey: string;
|
||||
}) => (
|
||||
<StaticRender width={width} key={itemKey}>
|
||||
{() => content}
|
||||
</StaticRender>
|
||||
),
|
||||
);
|
||||
|
||||
MemoizedStaticItem.displayName = 'MemoizedStaticItem';
|
||||
|
||||
const VirtualizedListItem = memo(
|
||||
({
|
||||
content,
|
||||
shouldBeStatic,
|
||||
width,
|
||||
containerWidth,
|
||||
itemKey,
|
||||
index,
|
||||
onSetRef,
|
||||
}: {
|
||||
content: React.ReactElement;
|
||||
shouldBeStatic: boolean;
|
||||
width: number | string | undefined;
|
||||
containerWidth: number;
|
||||
itemKey: string;
|
||||
index: number;
|
||||
onSetRef: (index: number, el: DOMElement | null) => void;
|
||||
onSetRef: (index: number, itemKey: string, el: DOMElement | null) => void;
|
||||
}) => {
|
||||
const itemRef = useCallback(
|
||||
(el: DOMElement | null) => {
|
||||
onSetRef(index, el);
|
||||
onSetRef(index, itemKey, el);
|
||||
},
|
||||
[index, onSetRef],
|
||||
[index, itemKey, onSetRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
|
||||
{shouldBeStatic ? (
|
||||
<StaticRender
|
||||
width={typeof width === 'number' ? width : containerWidth}
|
||||
key={
|
||||
itemKey +
|
||||
'-static-' +
|
||||
(typeof width === 'number' ? width : containerWidth)
|
||||
}
|
||||
>
|
||||
{content}
|
||||
</StaticRender>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
@@ -126,6 +126,20 @@ const VirtualizedListItem = memo(
|
||||
|
||||
VirtualizedListItem.displayName = 'VirtualizedListItem';
|
||||
|
||||
interface VirtualizedListInternalState {
|
||||
container: DOMElement | null;
|
||||
itemRefs: Array<DOMElement | null>;
|
||||
measuredHeights: number[];
|
||||
measuredKeys: string[];
|
||||
isInitialScrollSet: boolean;
|
||||
containerObserver: ResizeObserver | null;
|
||||
prevOffsetsLength: number;
|
||||
prevDataLength: number;
|
||||
prevTotalHeight: number;
|
||||
prevScrollTop: number;
|
||||
prevContainerHeight: number;
|
||||
}
|
||||
|
||||
function VirtualizedList<T>(
|
||||
props: VirtualizedListProps<T>,
|
||||
ref: React.Ref<VirtualizedListRef<T>>,
|
||||
@@ -144,15 +158,14 @@ function VirtualizedList<T>(
|
||||
overflowToBackbuffer,
|
||||
scrollbar = true,
|
||||
stableScrollback,
|
||||
copyModeEnabled = false,
|
||||
fixedItemHeight = false,
|
||||
maxScrollbackLength,
|
||||
} = props;
|
||||
const dataRef = useRef(data);
|
||||
useLayoutEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
const [scrollAnchor, setScrollAnchor] = useState(() => {
|
||||
const [scrollAnchor, setScrollAnchor] = useState<{
|
||||
index: number;
|
||||
offset: number;
|
||||
isBottom?: boolean;
|
||||
}>(() => {
|
||||
const scrollToEnd =
|
||||
initialScrollIndex === SCROLL_TO_ITEM_END ||
|
||||
(typeof initialScrollIndex === 'number' &&
|
||||
@@ -196,23 +209,87 @@ function VirtualizedList<T>(
|
||||
return scrollToEnd;
|
||||
});
|
||||
|
||||
const containerRef = useRef<DOMElement | null>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const itemRefs = useRef<Array<DOMElement | null>>([]);
|
||||
const [heights, setHeights] = useState<Record<string, number>>({});
|
||||
const isInitialScrollSet = useRef(false);
|
||||
const [measurementVersion, setMeasurementVersion] = useState(0);
|
||||
|
||||
const containerObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
|
||||
const state = useRef<VirtualizedListInternalState>({
|
||||
container: null,
|
||||
itemRefs: [],
|
||||
measuredHeights: [],
|
||||
measuredKeys: [],
|
||||
isInitialScrollSet: false,
|
||||
containerObserver: null,
|
||||
prevOffsetsLength: -1,
|
||||
prevDataLength: -1,
|
||||
prevTotalHeight: -1,
|
||||
prevScrollTop: -1,
|
||||
prevContainerHeight: -1,
|
||||
});
|
||||
|
||||
const onSetRef = useCallback((index: number, el: DOMElement | null) => {
|
||||
itemRefs.current[index] = el;
|
||||
}, []);
|
||||
const itemsObserver = useMemo(
|
||||
() =>
|
||||
new ResizeObserver((entries) => {
|
||||
let changed = false;
|
||||
for (const entry of entries) {
|
||||
// Extract index and key safely from the element
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
const target = entry.target as any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const index = target._virtualIndex;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const key = target._virtualKey;
|
||||
if (typeof index === 'number' && key !== undefined) {
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
// Ignore 0 height measurements which can happen when an element is unmounting
|
||||
if (
|
||||
height > 0 &&
|
||||
(state.current.measuredHeights[index] !== height ||
|
||||
state.current.measuredKeys[index] !== key)
|
||||
) {
|
||||
state.current.measuredHeights[index] = height;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
state.current.measuredKeys[index] = key;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
setMeasurementVersion((v) => v + 1);
|
||||
}
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const onSetRef = useCallback(
|
||||
(index: number, itemKey: string, el: DOMElement | null) => {
|
||||
const oldEl = state.current.itemRefs[index];
|
||||
if (oldEl && oldEl !== el) {
|
||||
if (!isStatic) {
|
||||
itemsObserver.unobserve(oldEl);
|
||||
}
|
||||
}
|
||||
|
||||
state.current.itemRefs[index] = el;
|
||||
|
||||
if (el) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
const target = el as any;
|
||||
|
||||
target._virtualIndex = index;
|
||||
|
||||
target._virtualKey = itemKey;
|
||||
if (!isStatic) {
|
||||
itemsObserver.observe(el);
|
||||
}
|
||||
}
|
||||
},
|
||||
[itemsObserver, isStatic],
|
||||
);
|
||||
|
||||
const containerRefCallback = useCallback((node: DOMElement | null) => {
|
||||
containerObserverRef.current?.disconnect();
|
||||
containerRef.current = node;
|
||||
state.current.containerObserver?.disconnect();
|
||||
state.current.container = node;
|
||||
if (node) {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
@@ -224,52 +301,33 @@ function VirtualizedList<T>(
|
||||
}
|
||||
});
|
||||
observer.observe(node);
|
||||
containerObserverRef.current = observer;
|
||||
state.current.containerObserver = observer;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const itemsObserver = useMemo(
|
||||
() =>
|
||||
new ResizeObserver((entries) => {
|
||||
setHeights((prev) => {
|
||||
let next: Record<string, number> | null = null;
|
||||
for (const entry of entries) {
|
||||
const key = nodeToKeyRef.current.get(entry.target);
|
||||
if (key !== undefined) {
|
||||
const height = Math.round(entry.contentRect.height);
|
||||
if (prev[key] !== height) {
|
||||
if (!next) {
|
||||
next = { ...prev };
|
||||
}
|
||||
next[key] = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next ?? prev;
|
||||
});
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useLayoutEffect(
|
||||
useEffect(
|
||||
() => () => {
|
||||
containerObserverRef.current?.disconnect();
|
||||
state.current.containerObserver?.disconnect();
|
||||
itemsObserver.disconnect();
|
||||
},
|
||||
[itemsObserver],
|
||||
);
|
||||
|
||||
const { totalHeight, offsets } = useMemo(() => {
|
||||
const { totalHeight, offsets } = (() => {
|
||||
const offsets: number[] = [0];
|
||||
let totalHeight = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const key = keyExtractor(data[i], i);
|
||||
const height = heights[key] ?? estimatedItemHeight(i);
|
||||
const cachedHeight =
|
||||
state.current.measuredKeys[i] === key
|
||||
? state.current.measuredHeights[i]
|
||||
: undefined;
|
||||
const height = cachedHeight ?? estimatedItemHeight(i);
|
||||
totalHeight += height;
|
||||
offsets.push(totalHeight);
|
||||
}
|
||||
return { totalHeight, offsets };
|
||||
}, [heights, data, estimatedItemHeight, keyExtractor]);
|
||||
})();
|
||||
|
||||
const scrollableContainerHeight = props.containerHeight ?? containerHeight;
|
||||
|
||||
@@ -277,7 +335,29 @@ function VirtualizedList<T>(
|
||||
(
|
||||
scrollTop: number,
|
||||
offsets: number[],
|
||||
): { index: number; offset: number } => {
|
||||
totalHeight: number,
|
||||
scrollableContainerHeight: number,
|
||||
): { index: number; offset: number; isBottom?: boolean } => {
|
||||
const isNearBottom =
|
||||
totalHeight > 0 &&
|
||||
scrollTop > (totalHeight - scrollableContainerHeight) / 2;
|
||||
|
||||
if (isNearBottom) {
|
||||
const scrollBottom = scrollTop + scrollableContainerHeight;
|
||||
const index = findLastIndex(
|
||||
offsets,
|
||||
(offset) => offset <= scrollBottom,
|
||||
);
|
||||
if (index === -1) {
|
||||
return { index: 0, offset: 0, isBottom: true };
|
||||
}
|
||||
return {
|
||||
index,
|
||||
offset: scrollBottom - offsets[index],
|
||||
isBottom: true,
|
||||
};
|
||||
}
|
||||
|
||||
const index = findLastIndex(offsets, (offset) => offset <= scrollTop);
|
||||
if (index === -1) {
|
||||
return { index: 0, offset: 0 };
|
||||
@@ -291,7 +371,6 @@ function VirtualizedList<T>(
|
||||
const [prevTargetScrollIndex, setPrevTargetScrollIndex] = useState(
|
||||
props.targetScrollIndex,
|
||||
);
|
||||
const prevOffsetsLength = useRef(offsets.length);
|
||||
|
||||
// NOTE: If targetScrollIndex is provided, and we haven't rendered items yet (offsets.length <= 1),
|
||||
// we do NOT set scrollAnchor yet, because actualScrollTop wouldn't know the real offset!
|
||||
@@ -301,17 +380,17 @@ function VirtualizedList<T>(
|
||||
props.targetScrollIndex !== prevTargetScrollIndex &&
|
||||
offsets.length > 1) ||
|
||||
(props.targetScrollIndex !== undefined &&
|
||||
prevOffsetsLength.current <= 1 &&
|
||||
state.current.prevOffsetsLength <= 1 &&
|
||||
offsets.length > 1)
|
||||
) {
|
||||
if (props.targetScrollIndex !== prevTargetScrollIndex) {
|
||||
setPrevTargetScrollIndex(props.targetScrollIndex);
|
||||
}
|
||||
prevOffsetsLength.current = offsets.length;
|
||||
state.current.prevOffsetsLength = offsets.length;
|
||||
setIsStickingToBottom(false);
|
||||
setScrollAnchor({ index: props.targetScrollIndex, offset: 0 });
|
||||
} else {
|
||||
prevOffsetsLength.current = offsets.length;
|
||||
} else if (offsets.length > 1) {
|
||||
state.current.prevOffsetsLength = offsets.length;
|
||||
}
|
||||
|
||||
const actualScrollTop = useMemo(() => {
|
||||
@@ -323,46 +402,58 @@ function VirtualizedList<T>(
|
||||
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
|
||||
const item = data[scrollAnchor.index];
|
||||
const key = item ? keyExtractor(item, scrollAnchor.index) : '';
|
||||
const itemHeight = heights[key] ?? 0;
|
||||
const cachedHeight =
|
||||
state.current.measuredKeys[scrollAnchor.index] === key
|
||||
? state.current.measuredHeights[scrollAnchor.index]
|
||||
: undefined;
|
||||
const itemHeight =
|
||||
cachedHeight ?? estimatedItemHeight(scrollAnchor.index) ?? 0;
|
||||
return offset + itemHeight - scrollableContainerHeight;
|
||||
}
|
||||
|
||||
if (scrollAnchor.isBottom) {
|
||||
return offset + scrollAnchor.offset - scrollableContainerHeight;
|
||||
}
|
||||
|
||||
return offset + scrollAnchor.offset;
|
||||
}, [
|
||||
scrollAnchor,
|
||||
offsets,
|
||||
heights,
|
||||
scrollableContainerHeight,
|
||||
data,
|
||||
keyExtractor,
|
||||
estimatedItemHeight,
|
||||
]);
|
||||
|
||||
const scrollTop = isStickingToBottom
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: actualScrollTop;
|
||||
|
||||
const prevDataLength = useRef(data.length);
|
||||
const prevTotalHeight = useRef(totalHeight);
|
||||
const prevScrollTop = useRef(actualScrollTop);
|
||||
const prevContainerHeight = useRef(scrollableContainerHeight);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (state.current.prevDataLength === -1) {
|
||||
state.current.prevDataLength = data.length;
|
||||
state.current.prevTotalHeight = totalHeight;
|
||||
state.current.prevScrollTop = actualScrollTop;
|
||||
state.current.prevContainerHeight = scrollableContainerHeight;
|
||||
return;
|
||||
}
|
||||
|
||||
const contentPreviouslyFit =
|
||||
prevTotalHeight.current <= prevContainerHeight.current;
|
||||
state.current.prevTotalHeight <= state.current.prevContainerHeight;
|
||||
const wasScrolledToBottomPixels =
|
||||
prevScrollTop.current >=
|
||||
prevTotalHeight.current - prevContainerHeight.current - 1;
|
||||
state.current.prevScrollTop >=
|
||||
state.current.prevTotalHeight - state.current.prevContainerHeight - 1;
|
||||
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
|
||||
|
||||
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
|
||||
if (wasAtBottom && actualScrollTop >= state.current.prevScrollTop) {
|
||||
if (!isStickingToBottom) {
|
||||
setIsStickingToBottom(true);
|
||||
}
|
||||
}
|
||||
|
||||
const listGrew = data.length > prevDataLength.current;
|
||||
const listGrew = data.length > state.current.prevDataLength;
|
||||
const containerChanged =
|
||||
prevContainerHeight.current !== scrollableContainerHeight;
|
||||
state.current.prevContainerHeight !== scrollableContainerHeight;
|
||||
|
||||
// If targetScrollIndex is provided, we NEVER auto-snap to the bottom
|
||||
// because the parent is explicitly managing the scroll position.
|
||||
@@ -393,23 +484,33 @@ function VirtualizedList<T>(
|
||||
) {
|
||||
// We still clamp the scroll top if it's completely out of bounds
|
||||
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
|
||||
const newAnchor = getAnchorForScrollTop(newScrollTop, offsets);
|
||||
const newAnchor = getAnchorForScrollTop(
|
||||
newScrollTop,
|
||||
offsets,
|
||||
totalHeight,
|
||||
scrollableContainerHeight,
|
||||
);
|
||||
if (
|
||||
scrollAnchor.index !== newAnchor.index ||
|
||||
scrollAnchor.offset !== newAnchor.offset
|
||||
scrollAnchor.offset !== newAnchor.offset ||
|
||||
scrollAnchor.isBottom !== newAnchor.isBottom
|
||||
) {
|
||||
setScrollAnchor(newAnchor);
|
||||
}
|
||||
} else if (data.length === 0) {
|
||||
if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) {
|
||||
if (
|
||||
scrollAnchor.index !== 0 ||
|
||||
scrollAnchor.offset !== 0 ||
|
||||
scrollAnchor.isBottom !== undefined
|
||||
) {
|
||||
setScrollAnchor({ index: 0, offset: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
prevDataLength.current = data.length;
|
||||
prevTotalHeight.current = totalHeight;
|
||||
prevScrollTop.current = actualScrollTop;
|
||||
prevContainerHeight.current = scrollableContainerHeight;
|
||||
state.current.prevDataLength = data.length;
|
||||
state.current.prevTotalHeight = totalHeight;
|
||||
state.current.prevScrollTop = actualScrollTop;
|
||||
state.current.prevContainerHeight = scrollableContainerHeight;
|
||||
}, [
|
||||
data.length,
|
||||
totalHeight,
|
||||
@@ -417,6 +518,7 @@ function VirtualizedList<T>(
|
||||
scrollableContainerHeight,
|
||||
scrollAnchor.index,
|
||||
scrollAnchor.offset,
|
||||
scrollAnchor.isBottom,
|
||||
getAnchorForScrollTop,
|
||||
offsets,
|
||||
isStickingToBottom,
|
||||
@@ -425,7 +527,7 @@ function VirtualizedList<T>(
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
isInitialScrollSet.current ||
|
||||
state.current.isInitialScrollSet ||
|
||||
offsets.length <= 1 ||
|
||||
totalHeight <= 0 ||
|
||||
scrollableContainerHeight <= 0
|
||||
@@ -435,7 +537,7 @@ function VirtualizedList<T>(
|
||||
|
||||
if (props.targetScrollIndex !== undefined) {
|
||||
// If we are strictly driving from targetScrollIndex, do not apply initialScrollIndex
|
||||
isInitialScrollSet.current = true;
|
||||
state.current.isInitialScrollSet = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,7 +553,7 @@ function VirtualizedList<T>(
|
||||
offset: SCROLL_TO_ITEM_END,
|
||||
});
|
||||
setIsStickingToBottom(true);
|
||||
isInitialScrollSet.current = true;
|
||||
state.current.isInitialScrollSet = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -464,8 +566,15 @@ function VirtualizedList<T>(
|
||||
Math.min(totalHeight - scrollableContainerHeight, newScrollTop),
|
||||
);
|
||||
|
||||
setScrollAnchor(getAnchorForScrollTop(clampedScrollTop, offsets));
|
||||
isInitialScrollSet.current = true;
|
||||
setScrollAnchor(
|
||||
getAnchorForScrollTop(
|
||||
clampedScrollTop,
|
||||
offsets,
|
||||
totalHeight,
|
||||
scrollableContainerHeight,
|
||||
),
|
||||
);
|
||||
state.current.isInitialScrollSet = true;
|
||||
}
|
||||
}, [
|
||||
initialScrollIndex,
|
||||
@@ -475,7 +584,7 @@ function VirtualizedList<T>(
|
||||
scrollableContainerHeight,
|
||||
getAnchorForScrollTop,
|
||||
data.length,
|
||||
heights,
|
||||
measurementVersion,
|
||||
props.targetScrollIndex,
|
||||
]);
|
||||
|
||||
@@ -493,48 +602,32 @@ function VirtualizedList<T>(
|
||||
? data.length - 1
|
||||
: Math.min(data.length - 1, endIndexOffset);
|
||||
|
||||
const topSpacerHeight =
|
||||
renderStatic === true || overflowToBackbuffer === true
|
||||
? 0
|
||||
: (offsets[startIndex] ?? 0);
|
||||
const bottomSpacerHeight = renderStatic
|
||||
? 0
|
||||
: totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
||||
|
||||
// Maintain a stable set of observed nodes using useLayoutEffect
|
||||
const observedNodes = useRef<Set<DOMElement>>(new Set());
|
||||
useLayoutEffect(() => {
|
||||
const currentNodes = new Set<DOMElement>();
|
||||
const observeStart = renderStatic || overflowToBackbuffer ? 0 : startIndex;
|
||||
const observeEnd = renderStatic ? data.length - 1 : endIndex;
|
||||
|
||||
for (let i = observeStart; i <= observeEnd; i++) {
|
||||
const node = itemRefs.current[i];
|
||||
const item = data[i];
|
||||
if (node && item) {
|
||||
currentNodes.add(node);
|
||||
const key = keyExtractor(item, i);
|
||||
// Always update the key mapping because React can reuse nodes at different indices/keys
|
||||
nodeToKeyRef.current.set(node, key);
|
||||
if (!isStatic && !fixedItemHeight && !observedNodes.current.has(node)) {
|
||||
itemsObserver.observe(node);
|
||||
}
|
||||
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);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
for (const node of observedNodes.current) {
|
||||
if (!currentNodes.has(node)) {
|
||||
if (!isStatic && !fixedItemHeight) {
|
||||
itemsObserver.unobserve(node);
|
||||
}
|
||||
nodeToKeyRef.current.delete(node);
|
||||
}
|
||||
}
|
||||
observedNodes.current = currentNodes;
|
||||
});
|
||||
return startIndex;
|
||||
}, [
|
||||
overflowToBackbuffer,
|
||||
maxScrollbackLength,
|
||||
actualScrollTop,
|
||||
offsets,
|
||||
startIndex,
|
||||
]);
|
||||
|
||||
const renderRangeStart =
|
||||
renderStatic || overflowToBackbuffer ? 0 : startIndex;
|
||||
const renderRangeEnd = renderStatic ? data.length - 1 : endIndex;
|
||||
const topSpacerHeight = offsets[renderRangeStart];
|
||||
const bottomSpacerHeight =
|
||||
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
|
||||
|
||||
const renderRangeEnd = endIndex;
|
||||
|
||||
// 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.
|
||||
@@ -564,18 +657,43 @@ function VirtualizedList<T>(
|
||||
const content = renderItem({ item, index: i });
|
||||
const key = keyExtractor(item, i);
|
||||
|
||||
items.push(
|
||||
<VirtualizedListItem
|
||||
key={key}
|
||||
itemKey={key}
|
||||
content={content}
|
||||
shouldBeStatic={shouldBeStatic}
|
||||
width={width}
|
||||
containerWidth={containerWidth}
|
||||
index={i}
|
||||
onSetRef={onSetRef}
|
||||
/>,
|
||||
);
|
||||
if (shouldBeStatic) {
|
||||
items.push(
|
||||
<MemoizedStaticItem
|
||||
key={`${key}-static`}
|
||||
itemKey={`${key}-static-${typeof width === 'number' ? width : containerWidth}`}
|
||||
width={typeof width === 'number' ? width : containerWidth}
|
||||
content={content}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
items.push(
|
||||
<VirtualizedListItem
|
||||
key={key}
|
||||
itemKey={key}
|
||||
content={content}
|
||||
index={i}
|
||||
onSetRef={onSetRef}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!renderStatic &&
|
||||
state.current.measuredKeys[i] !== key &&
|
||||
!shouldBeStatic
|
||||
) {
|
||||
const fillerHeight = Math.max(0, estimatedItemHeight(i) - 1);
|
||||
if (fillerHeight > 0) {
|
||||
items.push(
|
||||
<Box
|
||||
key={key + '-filler'}
|
||||
height={fillerHeight}
|
||||
flexShrink={0}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
@@ -593,6 +711,7 @@ function VirtualizedList<T>(
|
||||
width,
|
||||
containerWidth,
|
||||
onSetRef,
|
||||
estimatedItemHeight,
|
||||
]);
|
||||
|
||||
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
||||
@@ -614,7 +733,12 @@ function VirtualizedList<T>(
|
||||
}
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(
|
||||
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets),
|
||||
getAnchorForScrollTop(
|
||||
Math.min(newScrollTop, maxScroll),
|
||||
offsets,
|
||||
totalHeight,
|
||||
scrollableContainerHeight,
|
||||
),
|
||||
);
|
||||
},
|
||||
scrollTo: (offset: number) => {
|
||||
@@ -632,7 +756,14 @@ function VirtualizedList<T>(
|
||||
setIsStickingToBottom(false);
|
||||
const newScrollTop = Math.max(0, offset);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
setScrollAnchor(
|
||||
getAnchorForScrollTop(
|
||||
newScrollTop,
|
||||
offsets,
|
||||
totalHeight,
|
||||
scrollableContainerHeight,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
scrollToEnd: () => {
|
||||
@@ -669,7 +800,14 @@ function VirtualizedList<T>(
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
setScrollAnchor(
|
||||
getAnchorForScrollTop(
|
||||
newScrollTop,
|
||||
offsets,
|
||||
totalHeight,
|
||||
scrollableContainerHeight,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
scrollToItem: ({
|
||||
@@ -698,7 +836,14 @@ function VirtualizedList<T>(
|
||||
),
|
||||
);
|
||||
setPendingScrollTop(newScrollTop);
|
||||
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
|
||||
setScrollAnchor(
|
||||
getAnchorForScrollTop(
|
||||
newScrollTop,
|
||||
offsets,
|
||||
totalHeight,
|
||||
scrollableContainerHeight,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -727,28 +872,27 @@ function VirtualizedList<T>(
|
||||
return (
|
||||
<Box
|
||||
ref={containerRefCallback}
|
||||
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
|
||||
overflowY="scroll"
|
||||
overflowX="hidden"
|
||||
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
||||
scrollTop={scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
backgroundColor={props.backgroundColor}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={copyModeEnabled ? 0 : 1}
|
||||
paddingRight={1}
|
||||
overflowToBackbuffer={overflowToBackbuffer}
|
||||
scrollbar={scrollbar}
|
||||
stableScrollback={stableScrollback}
|
||||
>
|
||||
<Box
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
marginTop={copyModeEnabled ? -actualScrollTop : 0}
|
||||
>
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
{topSpacerHeight > 0 ? (
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
) : null}
|
||||
{renderedItems}
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
{bottomSpacerHeight > 0 ? (
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -7,41 +7,41 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│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 │
|
||||
│ │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -53,7 +53,7 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│Item 997 │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
|
||||
@@ -505,6 +505,13 @@
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxScrollbackLength": {
|
||||
"title": "Max Scrollback Length",
|
||||
"description": "Maximum number of lines to keep in the terminal scrollback buffer.",
|
||||
"markdownDescription": "Maximum number of lines to keep in the terminal scrollback buffer.\n\n- Category: `UI`\n- Requires restart: `yes`\n- Default: `1000`",
|
||||
"default": 1000,
|
||||
"type": "number"
|
||||
},
|
||||
"showSpinner": {
|
||||
"title": "Show Spinner",
|
||||
"description": "Show the spinner during operations.",
|
||||
|
||||
Reference in New Issue
Block a user