mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
perf(ui): optimize text buffer and highlighting for large inputs (#16782)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../../test-utils/render.js';
|
||||
import { useTextBuffer } from './text-buffer.js';
|
||||
import { parseInputForHighlighting } from '../../utils/highlight.js';
|
||||
|
||||
describe('text-buffer performance', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should handle pasting large amounts of text efficiently', () => {
|
||||
const viewport = { width: 80, height: 24 };
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
viewport,
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
|
||||
const lines = 5000;
|
||||
const largeText = Array.from(
|
||||
{ length: lines },
|
||||
(_, i) =>
|
||||
`Line ${i}: some sample text with many @path/to/image${i}.png and maybe some more @path/to/another/image.png references to trigger regex. This line is much longer than the previous one to test wrapping.`,
|
||||
).join('\n');
|
||||
|
||||
const start = Date.now();
|
||||
act(() => {
|
||||
result.current.insert(largeText, { paste: true });
|
||||
});
|
||||
const end = Date.now();
|
||||
|
||||
const duration = end - start;
|
||||
expect(duration).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('should handle character-by-character insertion in a large buffer efficiently', () => {
|
||||
const lines = 5000;
|
||||
const initialText = Array.from(
|
||||
{ length: lines },
|
||||
(_, i) => `Line ${i}: some sample text with @path/to/image.png`,
|
||||
).join('\n');
|
||||
const viewport = { width: 80, height: 24 };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
initialText,
|
||||
viewport,
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
|
||||
const start = Date.now();
|
||||
const charsToInsert = 100;
|
||||
for (let i = 0; i < charsToInsert; i++) {
|
||||
act(() => {
|
||||
result.current.insert('a');
|
||||
});
|
||||
}
|
||||
const end = Date.now();
|
||||
|
||||
const duration = end - start;
|
||||
expect(duration).toBeLessThan(5000);
|
||||
});
|
||||
|
||||
it('should highlight many lines efficiently', () => {
|
||||
const lines = 5000;
|
||||
const sampleLines = Array.from(
|
||||
{ length: lines },
|
||||
(_, i) =>
|
||||
`Line ${i}: some sample text with @path/to/image${i}.png /command and more @file.txt`,
|
||||
);
|
||||
|
||||
const start = Date.now();
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// Simulate 100 renders
|
||||
for (const line of sampleLines.slice(0, 20)) {
|
||||
// 20 visible lines
|
||||
parseInputForHighlighting(line, 1, []);
|
||||
}
|
||||
}
|
||||
const end = Date.now();
|
||||
|
||||
const duration = end - start;
|
||||
expect(duration).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,13 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../../test-utils/render.js';
|
||||
import {
|
||||
renderHook,
|
||||
renderHookWithProviders,
|
||||
} from '../../../test-utils/render.js';
|
||||
import type {
|
||||
Viewport,
|
||||
TextBuffer,
|
||||
@@ -55,6 +58,10 @@ const initialState: TextBufferState = {
|
||||
};
|
||||
|
||||
describe('textBufferReducer', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return the initial state if state is undefined', () => {
|
||||
const action = { type: 'unknown_action' } as unknown as TextBufferAction;
|
||||
const state = textBufferReducer(initialState, action);
|
||||
@@ -381,6 +388,10 @@ describe('useTextBuffer', () => {
|
||||
viewport = { width: 10, height: 3 }; // Default viewport for tests
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with empty text and cursor at (0,0) by default', () => {
|
||||
const { result } = renderHook(() =>
|
||||
@@ -2371,6 +2382,10 @@ describe('Unicode helper functions', () => {
|
||||
});
|
||||
|
||||
describe('Transformation Utilities', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getTransformedImagePath', () => {
|
||||
it('should transform a simple image path', () => {
|
||||
expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]');
|
||||
@@ -2547,4 +2562,125 @@ describe('Transformation Utilities', () => {
|
||||
expect(result.transformedToLogMap).toEqual([0]); // Just the trailing position
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout Caching and Invalidation', () => {
|
||||
it.each([
|
||||
{
|
||||
desc: 'via setText',
|
||||
actFn: (result: { current: TextBuffer }) =>
|
||||
result.current.setText('changed line'),
|
||||
expected: 'changed line',
|
||||
},
|
||||
{
|
||||
desc: 'via replaceRange',
|
||||
actFn: (result: { current: TextBuffer }) =>
|
||||
result.current.replaceRange(0, 0, 0, 13, 'changed line'),
|
||||
expected: 'changed line',
|
||||
},
|
||||
])(
|
||||
'should invalidate cache when line content changes $desc',
|
||||
({ actFn, expected }) => {
|
||||
const viewport = { width: 80, height: 24 };
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useTextBuffer({
|
||||
initialText: 'original line',
|
||||
viewport,
|
||||
isValidPath: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
const originalLayout = result.current.visualLayout;
|
||||
|
||||
act(() => {
|
||||
actFn(result);
|
||||
});
|
||||
|
||||
expect(result.current.visualLayout).not.toBe(originalLayout);
|
||||
expect(result.current.allVisualLines[0]).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it('should invalidate cache when viewport width changes', () => {
|
||||
const viewport = { width: 80, height: 24 };
|
||||
const { result, rerender } = renderHookWithProviders(
|
||||
({ vp }) =>
|
||||
useTextBuffer({
|
||||
initialText:
|
||||
'a very long line that will wrap when the viewport is small',
|
||||
viewport: vp,
|
||||
isValidPath: () => true,
|
||||
}),
|
||||
{ initialProps: { vp: viewport } },
|
||||
);
|
||||
|
||||
const originalLayout = result.current.visualLayout;
|
||||
|
||||
// Shrink viewport to force wrapping change
|
||||
rerender({ vp: { width: 10, height: 24 } });
|
||||
|
||||
expect(result.current.visualLayout).not.toBe(originalLayout);
|
||||
expect(result.current.allVisualLines.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('should correctly handle cursor expansion/collapse in cached layout', () => {
|
||||
const viewport = { width: 80, height: 24 };
|
||||
const text = 'Check @image.png here';
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useTextBuffer({
|
||||
initialText: text,
|
||||
viewport,
|
||||
isValidPath: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Cursor at start (collapsed)
|
||||
act(() => {
|
||||
result.current.moveToOffset(0);
|
||||
});
|
||||
expect(result.current.allVisualLines[0]).toContain('[Image image.png]');
|
||||
|
||||
// Move cursor onto the @path (expanded)
|
||||
act(() => {
|
||||
result.current.moveToOffset(7); // onto @
|
||||
});
|
||||
expect(result.current.allVisualLines[0]).toContain('@image.png');
|
||||
expect(result.current.allVisualLines[0]).not.toContain(
|
||||
'[Image image.png]',
|
||||
);
|
||||
|
||||
// Move cursor away (collapsed again)
|
||||
act(() => {
|
||||
result.current.moveToOffset(0);
|
||||
});
|
||||
expect(result.current.allVisualLines[0]).toContain('[Image image.png]');
|
||||
});
|
||||
|
||||
it('should reuse cache for unchanged lines during editing', () => {
|
||||
const viewport = { width: 80, height: 24 };
|
||||
const initialText = 'line 1\nline 2\nline 3';
|
||||
const { result } = renderHookWithProviders(() =>
|
||||
useTextBuffer({
|
||||
initialText,
|
||||
viewport,
|
||||
isValidPath: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
const layout1 = result.current.visualLayout;
|
||||
|
||||
// Edit line 1
|
||||
act(() => {
|
||||
result.current.moveToOffset(0);
|
||||
result.current.insert('X');
|
||||
});
|
||||
|
||||
const layout2 = result.current.visualLayout;
|
||||
expect(layout2).not.toBe(layout1);
|
||||
|
||||
// Verify that visual lines for line 2 and 3 (indices 1 and 2 in visualLines)
|
||||
// are identical in content if not in object reference (the arrays are rebuilt, but contents are cached)
|
||||
expect(result.current.allVisualLines[1]).toBe('line 2');
|
||||
expect(result.current.allVisualLines[2]).toBe('line 3');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type EditorType,
|
||||
getEditorCommand,
|
||||
isGuiEditor,
|
||||
LruCache,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
toCodePoints,
|
||||
@@ -31,6 +32,7 @@ import type { Key } from '../../contexts/KeypressContext.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
import type { VimAction } from './vim-buffer-actions.js';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
|
||||
|
||||
export type Direction =
|
||||
| 'left'
|
||||
@@ -726,9 +728,18 @@ export function getTransformedImagePath(filePath: string): string {
|
||||
return `[Image ${truncatedBase}${extension}]`;
|
||||
}
|
||||
|
||||
const transformationsCache = new LruCache<string, Transformation[]>(
|
||||
LRU_BUFFER_PERF_CACHE_LIMIT,
|
||||
);
|
||||
|
||||
export function calculateTransformationsForLine(
|
||||
line: string,
|
||||
): Transformation[] {
|
||||
const cached = transformationsCache.get(line);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const transformations: Transformation[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
@@ -748,6 +759,8 @@ export function calculateTransformationsForLine(
|
||||
});
|
||||
}
|
||||
|
||||
transformationsCache.set(line, transformations);
|
||||
|
||||
return transformations;
|
||||
}
|
||||
|
||||
@@ -845,6 +858,7 @@ export function calculateTransformedLine(
|
||||
|
||||
return { transformedLine, transformedToLogMap };
|
||||
}
|
||||
|
||||
export interface VisualLayout {
|
||||
visualLines: string[];
|
||||
// For each logical line, an array of [visualLineIndex, startColInLogical]
|
||||
@@ -858,6 +872,33 @@ export interface VisualLayout {
|
||||
visualToTransformedMap: number[];
|
||||
}
|
||||
|
||||
// Caches for layout calculation
|
||||
interface LineLayoutResult {
|
||||
visualLines: string[];
|
||||
logicalToVisualMap: Array<[number, number]>;
|
||||
visualToLogicalMap: Array<[number, number]>;
|
||||
transformedToLogMap: number[];
|
||||
visualToTransformedMap: number[];
|
||||
}
|
||||
|
||||
const lineLayoutCache = new LruCache<string, LineLayoutResult>(
|
||||
LRU_BUFFER_PERF_CACHE_LIMIT,
|
||||
);
|
||||
|
||||
function getLineLayoutCacheKey(
|
||||
line: string,
|
||||
viewportWidth: number,
|
||||
isCursorOnLine: boolean,
|
||||
cursorCol: number,
|
||||
): string {
|
||||
// Most lines (99.9% in a large buffer) are not cursor lines.
|
||||
// We use a simpler key for them to reduce string allocation overhead.
|
||||
if (!isCursorOnLine) {
|
||||
return `${viewportWidth}:N:${line}`;
|
||||
}
|
||||
return `${viewportWidth}:C:${cursorCol}:${line}`;
|
||||
}
|
||||
|
||||
// Calculates the visual wrapping of lines and the mapping between logical and visual coordinates.
|
||||
// This is an expensive operation and should be memoized.
|
||||
function calculateLayout(
|
||||
@@ -873,6 +914,34 @@ function calculateLayout(
|
||||
|
||||
logicalLines.forEach((logLine, logIndex) => {
|
||||
logicalToVisualMap[logIndex] = [];
|
||||
|
||||
const isCursorOnLine = logIndex === logicalCursor[0];
|
||||
const cacheKey = getLineLayoutCacheKey(
|
||||
logLine,
|
||||
viewportWidth,
|
||||
isCursorOnLine,
|
||||
logicalCursor[1],
|
||||
);
|
||||
const cached = lineLayoutCache.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const visualLineOffset = visualLines.length;
|
||||
visualLines.push(...cached.visualLines);
|
||||
cached.logicalToVisualMap.forEach(([relVisualIdx, logCol]) => {
|
||||
logicalToVisualMap[logIndex].push([
|
||||
visualLineOffset + relVisualIdx,
|
||||
logCol,
|
||||
]);
|
||||
});
|
||||
cached.visualToLogicalMap.forEach(([, logCol]) => {
|
||||
visualToLogicalMap.push([logIndex, logCol]);
|
||||
});
|
||||
transformedToLogicalMaps[logIndex] = cached.transformedToLogMap;
|
||||
visualToTransformedMap.push(...cached.visualToTransformedMap);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not in cache, calculate
|
||||
const transformations = calculateTransformationsForLine(logLine);
|
||||
const { transformedLine, transformedToLogMap } = calculateTransformedLine(
|
||||
logLine,
|
||||
@@ -880,13 +949,18 @@ function calculateLayout(
|
||||
logicalCursor,
|
||||
transformations,
|
||||
);
|
||||
transformedToLogicalMaps[logIndex] = transformedToLogMap;
|
||||
|
||||
const lineVisualLines: string[] = [];
|
||||
const lineLogicalToVisualMap: Array<[number, number]> = [];
|
||||
const lineVisualToLogicalMap: Array<[number, number]> = [];
|
||||
const lineVisualToTransformedMap: number[] = [];
|
||||
|
||||
if (transformedLine.length === 0) {
|
||||
// Handle empty logical line
|
||||
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
|
||||
visualToLogicalMap.push([logIndex, 0]);
|
||||
visualToTransformedMap.push(0);
|
||||
visualLines.push('');
|
||||
lineLogicalToVisualMap.push([0, 0]);
|
||||
lineVisualToLogicalMap.push([logIndex, 0]);
|
||||
lineVisualToTransformedMap.push(0);
|
||||
lineVisualLines.push('');
|
||||
} else {
|
||||
// Non-empty logical line
|
||||
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
|
||||
@@ -929,15 +1003,6 @@ function calculateLayout(
|
||||
// Single character is wider than viewport, take it anyway
|
||||
currentChunk = char;
|
||||
numCodePointsInChunk = 1;
|
||||
} else if (
|
||||
numCodePointsInChunk === 0 &&
|
||||
charVisualWidth <= viewportWidth
|
||||
) {
|
||||
// This case should ideally be caught by the next iteration if the char fits.
|
||||
// If it doesn't fit (because currentChunkVisualWidth was already > 0 from a previous char that filled the line),
|
||||
// then numCodePointsInChunk would not be 0.
|
||||
// This branch means the current char *itself* doesn't fit an empty line, which is handled by the above.
|
||||
// If we are here, it means the loop should break and the current chunk (which is empty) is finalized.
|
||||
}
|
||||
}
|
||||
break; // Break from inner loop to finalize this chunk
|
||||
@@ -955,55 +1020,57 @@ function calculateLayout(
|
||||
}
|
||||
}
|
||||
|
||||
// If the inner loop completed without breaking (i.e., remaining text fits)
|
||||
// or if the loop broke but numCodePointsInChunk is still 0 (e.g. first char too wide for empty line)
|
||||
if (
|
||||
numCodePointsInChunk === 0 &&
|
||||
currentPosInLogLine < codePointsInLogLine.length
|
||||
) {
|
||||
// This can happen if the very first character considered for a new visual line is wider than the viewport.
|
||||
// In this case, we take that single character.
|
||||
const firstChar = codePointsInLogLine[currentPosInLogLine];
|
||||
currentChunk = firstChar;
|
||||
numCodePointsInChunk = 1; // Ensure we advance
|
||||
}
|
||||
|
||||
// If after everything, numCodePointsInChunk is still 0 but we haven't processed the whole logical line,
|
||||
// it implies an issue, like viewportWidth being 0 or less. Avoid infinite loop.
|
||||
if (
|
||||
numCodePointsInChunk === 0 &&
|
||||
currentPosInLogLine < codePointsInLogLine.length
|
||||
) {
|
||||
// Force advance by one character to prevent infinite loop if something went wrong
|
||||
currentChunk = codePointsInLogLine[currentPosInLogLine];
|
||||
numCodePointsInChunk = 1;
|
||||
}
|
||||
|
||||
const logicalStartCol = transformedToLogMap[currentPosInLogLine] ?? 0;
|
||||
logicalToVisualMap[logIndex].push([
|
||||
visualLines.length,
|
||||
logicalStartCol,
|
||||
]);
|
||||
visualToLogicalMap.push([logIndex, logicalStartCol]);
|
||||
visualToTransformedMap.push(currentPosInLogLine);
|
||||
visualLines.push(currentChunk);
|
||||
lineLogicalToVisualMap.push([lineVisualLines.length, logicalStartCol]);
|
||||
lineVisualToLogicalMap.push([logIndex, logicalStartCol]);
|
||||
lineVisualToTransformedMap.push(currentPosInLogLine);
|
||||
lineVisualLines.push(currentChunk);
|
||||
|
||||
const logicalStartOfThisChunk = currentPosInLogLine;
|
||||
currentPosInLogLine += numCodePointsInChunk;
|
||||
|
||||
// If the chunk processed did not consume the entire logical line,
|
||||
// and the character immediately following the chunk is a space,
|
||||
// advance past this space as it acted as a delimiter for word wrapping.
|
||||
if (
|
||||
logicalStartOfThisChunk + numCodePointsInChunk <
|
||||
codePointsInLogLine.length &&
|
||||
currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe
|
||||
currentPosInLogLine < codePointsInLogLine.length &&
|
||||
codePointsInLogLine[currentPosInLogLine] === ' '
|
||||
) {
|
||||
currentPosInLogLine++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result for this line
|
||||
lineLayoutCache.set(cacheKey, {
|
||||
visualLines: lineVisualLines,
|
||||
logicalToVisualMap: lineLogicalToVisualMap,
|
||||
visualToLogicalMap: lineVisualToLogicalMap,
|
||||
transformedToLogMap,
|
||||
visualToTransformedMap: lineVisualToTransformedMap,
|
||||
});
|
||||
|
||||
const visualLineOffset = visualLines.length;
|
||||
visualLines.push(...lineVisualLines);
|
||||
lineLogicalToVisualMap.forEach(([relVisualIdx, logCol]) => {
|
||||
logicalToVisualMap[logIndex].push([
|
||||
visualLineOffset + relVisualIdx,
|
||||
logCol,
|
||||
]);
|
||||
});
|
||||
lineVisualToLogicalMap.forEach(([, logCol]) => {
|
||||
visualToLogicalMap.push([logIndex, logCol]);
|
||||
});
|
||||
transformedToLogicalMaps[logIndex] = transformedToLogMap;
|
||||
visualToTransformedMap.push(...lineVisualToTransformedMap);
|
||||
});
|
||||
|
||||
// If the entire logical text was empty, ensure there's one empty visual line.
|
||||
@@ -2378,6 +2445,7 @@ export function useTextBuffer({
|
||||
transformedToLogicalMaps,
|
||||
visualToTransformedMap,
|
||||
transformationsByLine,
|
||||
visualLayout,
|
||||
setText,
|
||||
insert,
|
||||
newline,
|
||||
@@ -2447,6 +2515,7 @@ export function useTextBuffer({
|
||||
transformedToLogicalMaps,
|
||||
visualToTransformedMap,
|
||||
transformationsByLine,
|
||||
visualLayout,
|
||||
setText,
|
||||
insert,
|
||||
newline,
|
||||
@@ -2540,6 +2609,7 @@ export interface TextBuffer {
|
||||
visualToTransformedMap: number[];
|
||||
/** Cached transformations per logical line */
|
||||
transformationsByLine: Transformation[][];
|
||||
visualLayout: VisualLayout;
|
||||
|
||||
// Actions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user