perf(ui): optimize text buffer and highlighting for large inputs (#16782)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
N. Taylor Mullen
2026-01-16 09:33:13 -08:00
committed by GitHub
parent fcd860e1b0
commit be37c26c88
10 changed files with 469 additions and 76 deletions
@@ -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 * 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 stripAnsi from 'strip-ansi';
import { act } from 'react'; import { act } from 'react';
import { renderHook } from '../../../test-utils/render.js'; import {
renderHook,
renderHookWithProviders,
} from '../../../test-utils/render.js';
import type { import type {
Viewport, Viewport,
TextBuffer, TextBuffer,
@@ -55,6 +58,10 @@ const initialState: TextBufferState = {
}; };
describe('textBufferReducer', () => { describe('textBufferReducer', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should return the initial state if state is undefined', () => { it('should return the initial state if state is undefined', () => {
const action = { type: 'unknown_action' } as unknown as TextBufferAction; const action = { type: 'unknown_action' } as unknown as TextBufferAction;
const state = textBufferReducer(initialState, action); const state = textBufferReducer(initialState, action);
@@ -381,6 +388,10 @@ describe('useTextBuffer', () => {
viewport = { width: 10, height: 3 }; // Default viewport for tests viewport = { width: 10, height: 3 }; // Default viewport for tests
}); });
afterEach(() => {
vi.restoreAllMocks();
});
describe('Initialization', () => { describe('Initialization', () => {
it('should initialize with empty text and cursor at (0,0) by default', () => { it('should initialize with empty text and cursor at (0,0) by default', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
@@ -2371,6 +2382,10 @@ describe('Unicode helper functions', () => {
}); });
describe('Transformation Utilities', () => { describe('Transformation Utilities', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('getTransformedImagePath', () => { describe('getTransformedImagePath', () => {
it('should transform a simple image path', () => { it('should transform a simple image path', () => {
expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]'); expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]');
@@ -2547,4 +2562,125 @@ describe('Transformation Utilities', () => {
expect(result.transformedToLogMap).toEqual([0]); // Just the trailing position 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, type EditorType,
getEditorCommand, getEditorCommand,
isGuiEditor, isGuiEditor,
LruCache,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { import {
toCodePoints, toCodePoints,
@@ -31,6 +32,7 @@ import type { Key } from '../../contexts/KeypressContext.js';
import { keyMatchers, Command } from '../../keyMatchers.js'; import { keyMatchers, Command } from '../../keyMatchers.js';
import type { VimAction } from './vim-buffer-actions.js'; import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
export type Direction = export type Direction =
| 'left' | 'left'
@@ -726,9 +728,18 @@ export function getTransformedImagePath(filePath: string): string {
return `[Image ${truncatedBase}${extension}]`; return `[Image ${truncatedBase}${extension}]`;
} }
const transformationsCache = new LruCache<string, Transformation[]>(
LRU_BUFFER_PERF_CACHE_LIMIT,
);
export function calculateTransformationsForLine( export function calculateTransformationsForLine(
line: string, line: string,
): Transformation[] { ): Transformation[] {
const cached = transformationsCache.get(line);
if (cached) {
return cached;
}
const transformations: Transformation[] = []; const transformations: Transformation[] = [];
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
@@ -748,6 +759,8 @@ export function calculateTransformationsForLine(
}); });
} }
transformationsCache.set(line, transformations);
return transformations; return transformations;
} }
@@ -845,6 +858,7 @@ export function calculateTransformedLine(
return { transformedLine, transformedToLogMap }; return { transformedLine, transformedToLogMap };
} }
export interface VisualLayout { export interface VisualLayout {
visualLines: string[]; visualLines: string[];
// For each logical line, an array of [visualLineIndex, startColInLogical] // For each logical line, an array of [visualLineIndex, startColInLogical]
@@ -858,6 +872,33 @@ export interface VisualLayout {
visualToTransformedMap: number[]; 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. // Calculates the visual wrapping of lines and the mapping between logical and visual coordinates.
// This is an expensive operation and should be memoized. // This is an expensive operation and should be memoized.
function calculateLayout( function calculateLayout(
@@ -873,6 +914,34 @@ function calculateLayout(
logicalLines.forEach((logLine, logIndex) => { logicalLines.forEach((logLine, logIndex) => {
logicalToVisualMap[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 transformations = calculateTransformationsForLine(logLine);
const { transformedLine, transformedToLogMap } = calculateTransformedLine( const { transformedLine, transformedToLogMap } = calculateTransformedLine(
logLine, logLine,
@@ -880,13 +949,18 @@ function calculateLayout(
logicalCursor, logicalCursor,
transformations, transformations,
); );
transformedToLogicalMaps[logIndex] = transformedToLogMap;
const lineVisualLines: string[] = [];
const lineLogicalToVisualMap: Array<[number, number]> = [];
const lineVisualToLogicalMap: Array<[number, number]> = [];
const lineVisualToTransformedMap: number[] = [];
if (transformedLine.length === 0) { if (transformedLine.length === 0) {
// Handle empty logical line // Handle empty logical line
logicalToVisualMap[logIndex].push([visualLines.length, 0]); lineLogicalToVisualMap.push([0, 0]);
visualToLogicalMap.push([logIndex, 0]); lineVisualToLogicalMap.push([logIndex, 0]);
visualToTransformedMap.push(0); lineVisualToTransformedMap.push(0);
visualLines.push(''); lineVisualLines.push('');
} else { } else {
// Non-empty logical line // Non-empty logical line
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index) 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 // Single character is wider than viewport, take it anyway
currentChunk = char; currentChunk = char;
numCodePointsInChunk = 1; 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 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 ( if (
numCodePointsInChunk === 0 && numCodePointsInChunk === 0 &&
currentPosInLogLine < codePointsInLogLine.length 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]; const firstChar = codePointsInLogLine[currentPosInLogLine];
currentChunk = firstChar; 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; numCodePointsInChunk = 1;
} }
const logicalStartCol = transformedToLogMap[currentPosInLogLine] ?? 0; const logicalStartCol = transformedToLogMap[currentPosInLogLine] ?? 0;
logicalToVisualMap[logIndex].push([ lineLogicalToVisualMap.push([lineVisualLines.length, logicalStartCol]);
visualLines.length, lineVisualToLogicalMap.push([logIndex, logicalStartCol]);
logicalStartCol, lineVisualToTransformedMap.push(currentPosInLogLine);
]); lineVisualLines.push(currentChunk);
visualToLogicalMap.push([logIndex, logicalStartCol]);
visualToTransformedMap.push(currentPosInLogLine);
visualLines.push(currentChunk);
const logicalStartOfThisChunk = currentPosInLogLine; const logicalStartOfThisChunk = currentPosInLogLine;
currentPosInLogLine += numCodePointsInChunk; 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 ( if (
logicalStartOfThisChunk + numCodePointsInChunk < logicalStartOfThisChunk + numCodePointsInChunk <
codePointsInLogLine.length && codePointsInLogLine.length &&
currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe currentPosInLogLine < codePointsInLogLine.length &&
codePointsInLogLine[currentPosInLogLine] === ' ' codePointsInLogLine[currentPosInLogLine] === ' '
) { ) {
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. // If the entire logical text was empty, ensure there's one empty visual line.
@@ -2378,6 +2445,7 @@ export function useTextBuffer({
transformedToLogicalMaps, transformedToLogicalMaps,
visualToTransformedMap, visualToTransformedMap,
transformationsByLine, transformationsByLine,
visualLayout,
setText, setText,
insert, insert,
newline, newline,
@@ -2447,6 +2515,7 @@ export function useTextBuffer({
transformedToLogicalMaps, transformedToLogicalMaps,
visualToTransformedMap, visualToTransformedMap,
transformationsByLine, transformationsByLine,
visualLayout,
setText, setText,
insert, insert,
newline, newline,
@@ -2540,6 +2609,7 @@ export interface TextBuffer {
visualToTransformedMap: number[]; visualToTransformedMap: number[];
/** Cached transformations per logical line */ /** Cached transformations per logical line */
transformationsByLine: Transformation[][]; transformationsByLine: Transformation[][];
visualLayout: VisualLayout;
// Actions // Actions
+1
View File
@@ -32,3 +32,4 @@ export const MAX_MCP_RESOURCES_TO_SHOW = 10;
export const WARNING_PROMPT_DURATION_MS = 1000; export const WARNING_PROMPT_DURATION_MS = 1000;
export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000; export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000;
export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000;
+2 -2
View File
@@ -212,13 +212,13 @@ describe('parseInputForHighlighting with Transformations', () => {
}); });
it('should handle empty transformations array', () => { it('should handle empty transformations array', () => {
const line = 'Check out @test.png'; const line = 'Check out @test_no_transform.png';
const result = parseInputForHighlighting(line, 0, [], 0); const result = parseInputForHighlighting(line, 0, [], 0);
// Should fall back to default highlighting // Should fall back to default highlighting
expect(result).toEqual([ expect(result).toEqual([
{ text: 'Check out ', type: 'default' }, { text: 'Check out ', type: 'default' },
{ text: '@test.png', type: 'file' }, { text: '@test_no_transform.png', type: 'file' },
]); ]);
}); });
+24 -1
View File
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { LruCache } from '@google/gemini-cli-core';
import type { Transformation } from '../components/shared/text-buffer.js'; import type { Transformation } from '../components/shared/text-buffer.js';
import { cpLen, cpSlice } from './textUtils.js'; import { cpLen, cpSlice } from './textUtils.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js';
export type HighlightToken = { export type HighlightToken = {
text: string; text: string;
@@ -18,12 +20,30 @@ export type HighlightToken = {
// semicolon, common punctuation, and brackets. // semicolon, common punctuation, and brackets.
const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[^,\s;!?()[\]{}])+)/g; const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[^,\s;!?()[\]{}])+)/g;
const highlightCache = new LruCache<string, readonly HighlightToken[]>(
LRU_BUFFER_PERF_CACHE_LIMIT,
);
export function parseInputForHighlighting( export function parseInputForHighlighting(
text: string, text: string,
index: number, index: number,
transformations: Transformation[] = [], transformations: Transformation[] = [],
cursorCol?: number, cursorCol?: number,
): readonly HighlightToken[] { ): readonly HighlightToken[] {
let isCursorInsideTransform = false;
if (cursorCol !== undefined) {
for (const transform of transformations) {
if (cursorCol >= transform.logStart && cursorCol <= transform.logEnd) {
isCursorInsideTransform = true;
break;
}
}
}
const cacheKey = `${index === 0 ? 'F' : 'N'}:${isCursorInsideTransform ? cursorCol : 'NC'}:${text}`;
const cached = highlightCache.get(cacheKey);
if (cached !== undefined) return cached;
HIGHLIGHT_REGEX.lastIndex = 0; HIGHLIGHT_REGEX.lastIndex = 0;
if (!text) { if (!text) {
@@ -79,7 +99,7 @@ export function parseInputForHighlighting(
tokens.push(...parseUntransformedInput(textBeforeTransformation)); tokens.push(...parseUntransformedInput(textBeforeTransformation));
const isCursorInside = const isCursorInside =
typeof cursorCol === 'number' && cursorCol !== undefined &&
cursorCol >= transformation.logStart && cursorCol >= transformation.logStart &&
cursorCol <= transformation.logEnd; cursorCol <= transformation.logEnd;
const transformationText = isCursorInside const transformationText = isCursorInside
@@ -92,6 +112,9 @@ export function parseInputForHighlighting(
const textAfterFinalTransformation = cpSlice(text, column); const textAfterFinalTransformation = cpSlice(text, column);
tokens.push(...parseUntransformedInput(textAfterFinalTransformation)); tokens.push(...parseUntransformedInput(textAfterFinalTransformation));
highlightCache.set(cacheKey, tokens);
return tokens; return tokens;
} }
+21 -12
View File
@@ -8,6 +8,8 @@ import stripAnsi from 'strip-ansi';
import ansiRegex from 'ansi-regex'; import ansiRegex from 'ansi-regex';
import { stripVTControlCharacters } from 'node:util'; import { stripVTControlCharacters } from 'node:util';
import stringWidth from 'string-width'; import stringWidth from 'string-width';
import { LruCache } from '@google/gemini-cli-core';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js';
/** /**
* Calculates the maximum width of a multi-line ASCII art string. * Calculates the maximum width of a multi-line ASCII art string.
@@ -28,9 +30,11 @@ export const getAsciiArtWidth = (asciiArt: string): number => {
* code units so that surrogatepair emoji count as one "column".) * code units so that surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */ * ---------------------------------------------------------------------- */
// Cache for code points to reduce GC pressure // Cache for code points
const codePointsCache = new Map<string, string[]>();
const MAX_STRING_LENGTH_TO_CACHE = 1000; const MAX_STRING_LENGTH_TO_CACHE = 1000;
const codePointsCache = new LruCache<string, string[]>(
LRU_BUFFER_PERF_CACHE_LIMIT,
);
export function toCodePoints(str: string): string[] { export function toCodePoints(str: string): string[] {
// ASCII fast path - check if all chars are ASCII (0-127) // ASCII fast path - check if all chars are ASCII (0-127)
@@ -48,14 +52,14 @@ export function toCodePoints(str: string): string[] {
// Cache short strings // Cache short strings
if (str.length <= MAX_STRING_LENGTH_TO_CACHE) { if (str.length <= MAX_STRING_LENGTH_TO_CACHE) {
const cached = codePointsCache.get(str); const cached = codePointsCache.get(str);
if (cached) { if (cached !== undefined) {
return cached; return cached;
} }
} }
const result = Array.from(str); const result = Array.from(str);
// Cache result (unlimited like Ink) // Cache result
if (str.length <= MAX_STRING_LENGTH_TO_CACHE) { if (str.length <= MAX_STRING_LENGTH_TO_CACHE) {
codePointsCache.set(str, result); codePointsCache.set(str, result);
} }
@@ -119,21 +123,26 @@ export function stripUnsafeCharacters(str: string): string {
.join(''); .join('');
} }
// String width caching for performance optimization const stringWidthCache = new LruCache<string, number>(
const stringWidthCache = new Map<string, number>(); LRU_BUFFER_PERF_CACHE_LIMIT,
);
/** /**
* Cached version of stringWidth function for better performance * Cached version of stringWidth function for better performance
* Follows Ink's approach with unlimited cache (no eviction)
*/ */
export const getCachedStringWidth = (str: string): number => { export const getCachedStringWidth = (str: string): number => {
// ASCII printable chars have width 1 // ASCII printable chars (32-126) have width 1.
if (/^[\x20-\x7E]*$/.test(str)) { // This is a very frequent path, so we use a fast numeric check.
return str.length; if (str.length === 1) {
const code = str.charCodeAt(0);
if (code >= 0x20 && code <= 0x7e) {
return 1;
}
} }
if (stringWidthCache.has(str)) { const cached = stringWidthCache.get(str);
return stringWidthCache.get(str)!; if (cached !== undefined) {
return cached;
} }
let width: number; let width: number;
+1
View File
@@ -92,6 +92,7 @@ export * from './utils/version.js';
export * from './utils/checkpointUtils.js'; export * from './utils/checkpointUtils.js';
export * from './utils/apiConversionUtils.js'; export * from './utils/apiConversionUtils.js';
export * from './utils/channel.js'; export * from './utils/channel.js';
export * from './utils/LruCache.js';
// Export services // Export services
export * from './services/fileDiscoveryService.js'; export * from './services/fileDiscoveryService.js';
+70
View File
@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { LruCache } from './LruCache.js';
describe('LruCache', () => {
it('should store and retrieve values', () => {
const cache = new LruCache<string, number>(10);
cache.set('a', 1);
expect(cache.get('a')).toBe(1);
expect(cache.get('b')).toBeUndefined();
});
it('should evict oldest items when limit is reached', () => {
const cache = new LruCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
expect(cache.get('a')).toBeUndefined();
expect(cache.get('b')).toBe(2);
expect(cache.get('c')).toBe(3);
});
it('should update LRU order on get', () => {
const cache = new LruCache<string, number>(2);
cache.set('a', 1);
cache.set('b', 2);
cache.get('a'); // Mark 'a' as recently used
cache.set('c', 3); // Should evict 'b'
expect(cache.get('b')).toBeUndefined();
expect(cache.get('a')).toBe(1);
expect(cache.get('c')).toBe(3);
});
it('should correctly handle falsy values (truthiness bug fix)', () => {
const cache = new LruCache<string, unknown>(2);
cache.set('zero', 0);
cache.set('empty', '');
// Both should be in cache
expect(cache.get('zero')).toBe(0);
expect(cache.get('empty')).toBe('');
// Access 'zero' to move it to end
cache.get('zero');
// Add new item, should evict 'empty' (because 'zero' was just accessed)
cache.set('new', 'item');
expect(cache.get('empty')).toBeUndefined();
expect(cache.get('zero')).toBe(0);
expect(cache.get('new')).toBe('item');
});
it('should correctly handle undefined as a value', () => {
// NOTE: mnemonist LRUMap returns undefined if not found.
// If we store undefined, we can't distinguish between "not found" and "found undefined".
// But we should at least check its behavior.
const cache = new LruCache<string, unknown>(10);
cache.set('key', undefined);
expect(cache.has('key')).toBe(true);
expect(cache.get('key')).toBeUndefined();
});
});
+9 -19
View File
@@ -4,34 +4,24 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { LRUMap } from 'mnemonist';
export class LruCache<K, V> { export class LruCache<K, V> {
private cache: Map<K, V>; private cache: LRUMap<K, V>;
private maxSize: number;
constructor(maxSize: number) { constructor(maxSize: number) {
this.cache = new Map<K, V>(); this.cache = new LRUMap<K, V>(maxSize);
this.maxSize = maxSize;
} }
get(key: K): V | undefined { get(key: K): V | undefined {
const value = this.cache.get(key); return this.cache.get(key);
if (value) { }
// Move to end to mark as recently used
this.cache.delete(key); has(key: K): boolean {
this.cache.set(key, value); return this.cache.has(key);
}
return value;
} }
set(key: K, value: V): void { set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, value); this.cache.set(key, value);
} }