mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat: terse transformations of image paths in text buffer (#4924)
Co-authored-by: Jacob Richman <jacob314@gmail.com> Co-authored-by: owenofbrien <86964623+owenofbrien@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,10 @@ import { act } from 'react';
|
|||||||
import type { InputPromptProps } from './InputPrompt.js';
|
import type { InputPromptProps } from './InputPrompt.js';
|
||||||
import { InputPrompt } from './InputPrompt.js';
|
import { InputPrompt } from './InputPrompt.js';
|
||||||
import type { TextBuffer } from './shared/text-buffer.js';
|
import type { TextBuffer } from './shared/text-buffer.js';
|
||||||
|
import {
|
||||||
|
calculateTransformationsForLine,
|
||||||
|
calculateTransformedLine,
|
||||||
|
} from './shared/text-buffer.js';
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
@@ -158,6 +162,8 @@ describe('InputPrompt', () => {
|
|||||||
deleteWordLeft: vi.fn(),
|
deleteWordLeft: vi.fn(),
|
||||||
deleteWordRight: vi.fn(),
|
deleteWordRight: vi.fn(),
|
||||||
visualToLogicalMap: [[0, 0]],
|
visualToLogicalMap: [[0, 0]],
|
||||||
|
visualToTransformedMap: [0],
|
||||||
|
transformationsByLine: [],
|
||||||
getOffset: vi.fn().mockReturnValue(0),
|
getOffset: vi.fn().mockReturnValue(0),
|
||||||
} as unknown as TextBuffer;
|
} as unknown as TextBuffer;
|
||||||
|
|
||||||
@@ -2740,6 +2746,59 @@ describe('InputPrompt', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('image path transformation snapshots', () => {
|
||||||
|
const logicalLine = '@/path/to/screenshots/screenshot2x.png';
|
||||||
|
const transformations = calculateTransformationsForLine(logicalLine);
|
||||||
|
|
||||||
|
const applyVisualState = (visualLine: string, cursorCol: number): void => {
|
||||||
|
mockBuffer.text = logicalLine;
|
||||||
|
mockBuffer.lines = [logicalLine];
|
||||||
|
mockBuffer.viewportVisualLines = [visualLine];
|
||||||
|
mockBuffer.allVisualLines = [visualLine];
|
||||||
|
mockBuffer.visualToLogicalMap = [[0, 0]];
|
||||||
|
mockBuffer.visualToTransformedMap = [0];
|
||||||
|
mockBuffer.transformationsByLine = [transformations];
|
||||||
|
mockBuffer.cursor = [0, cursorCol];
|
||||||
|
mockBuffer.visualCursor = [0, 0];
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should snapshot collapsed image path', async () => {
|
||||||
|
const { transformedLine } = calculateTransformedLine(
|
||||||
|
logicalLine,
|
||||||
|
0,
|
||||||
|
[0, transformations[0].logEnd + 5],
|
||||||
|
transformations,
|
||||||
|
);
|
||||||
|
applyVisualState(transformedLine, transformations[0].logEnd + 5);
|
||||||
|
|
||||||
|
const { stdout, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should snapshot expanded image path when cursor is on it', async () => {
|
||||||
|
const { transformedLine } = calculateTransformedLine(
|
||||||
|
logicalLine,
|
||||||
|
0,
|
||||||
|
[0, transformations[0].logStart + 1],
|
||||||
|
transformations,
|
||||||
|
);
|
||||||
|
applyVisualState(transformedLine, transformations[0].logStart + 1);
|
||||||
|
|
||||||
|
const { stdout, unmount } = renderWithProviders(
|
||||||
|
<InputPrompt {...props} />,
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function clean(str: string | undefined): string {
|
function clean(str: string | undefined): string {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import type { Config } from '@google/gemini-cli-core';
|
|||||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
parseInputForHighlighting,
|
parseInputForHighlighting,
|
||||||
buildSegmentsForVisualSlice,
|
parseSegmentsFromTokens,
|
||||||
} from '../utils/highlight.js';
|
} from '../utils/highlight.js';
|
||||||
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
|
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
|
||||||
import {
|
import {
|
||||||
@@ -1101,21 +1101,27 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
|
|
||||||
const renderedLine: React.ReactNode[] = [];
|
const renderedLine: React.ReactNode[] = [];
|
||||||
|
|
||||||
const [logicalLineIdx, logicalStartCol] = mapEntry;
|
const [logicalLineIdx] = mapEntry;
|
||||||
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
const logicalLine = buffer.lines[logicalLineIdx] || '';
|
||||||
|
const transformations =
|
||||||
|
buffer.transformationsByLine[logicalLineIdx] ?? [];
|
||||||
const tokens = parseInputForHighlighting(
|
const tokens = parseInputForHighlighting(
|
||||||
logicalLine,
|
logicalLine,
|
||||||
logicalLineIdx,
|
logicalLineIdx,
|
||||||
|
transformations,
|
||||||
|
...(focus && buffer.cursor[0] === logicalLineIdx
|
||||||
|
? [buffer.cursor[1]]
|
||||||
|
: []),
|
||||||
);
|
);
|
||||||
|
const startColInTransformed =
|
||||||
const visualStart = logicalStartCol;
|
buffer.visualToTransformedMap[absoluteVisualIdx] ?? 0;
|
||||||
const visualEnd = logicalStartCol + cpLen(lineText);
|
const visualStartCol = startColInTransformed;
|
||||||
const segments = buildSegmentsForVisualSlice(
|
const visualEndCol = visualStartCol + cpLen(lineText);
|
||||||
|
const segments = parseSegmentsFromTokens(
|
||||||
tokens,
|
tokens,
|
||||||
visualStart,
|
visualStartCol,
|
||||||
visualEnd,
|
visualEndCol,
|
||||||
);
|
);
|
||||||
|
|
||||||
let charCount = 0;
|
let charCount = 0;
|
||||||
segments.forEach((seg, segIdx) => {
|
segments.forEach((seg, segIdx) => {
|
||||||
const segLen = cpLen(seg.text);
|
const segLen = cpLen(seg.text);
|
||||||
@@ -1131,7 +1137,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
relativeVisualColForHighlight < segEnd
|
relativeVisualColForHighlight < segEnd
|
||||||
) {
|
) {
|
||||||
const charToHighlight = cpSlice(
|
const charToHighlight = cpSlice(
|
||||||
seg.text,
|
display,
|
||||||
relativeVisualColForHighlight - segStart,
|
relativeVisualColForHighlight - segStart,
|
||||||
relativeVisualColForHighlight - segStart + 1,
|
relativeVisualColForHighlight - segStart + 1,
|
||||||
);
|
);
|
||||||
@@ -1140,17 +1146,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
: charToHighlight;
|
: charToHighlight;
|
||||||
display =
|
display =
|
||||||
cpSlice(
|
cpSlice(
|
||||||
seg.text,
|
display,
|
||||||
0,
|
0,
|
||||||
relativeVisualColForHighlight - segStart,
|
relativeVisualColForHighlight - segStart,
|
||||||
) +
|
) +
|
||||||
highlighted +
|
highlighted +
|
||||||
cpSlice(
|
cpSlice(
|
||||||
seg.text,
|
display,
|
||||||
relativeVisualColForHighlight - segStart + 1,
|
relativeVisualColForHighlight - segStart + 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
charCount = segEnd;
|
charCount = segEnd;
|
||||||
|
} else {
|
||||||
|
// Advance the running counter even when not on cursor line
|
||||||
|
charCount += segLen;
|
||||||
}
|
}
|
||||||
|
|
||||||
const color =
|
const color =
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match
|
|||||||
git commit -m "feat: add search" in src/app"
|
git commit -m "feat: add search" in src/app"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
|
||||||
|
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ > [Image ...reenshot2x.png] │
|
||||||
|
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
|
||||||
|
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ > @/path/to/screenshots/screenshot2x.png │
|
||||||
|
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||||
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
│ > Type your message or @path/to/file │
|
│ > Type your message or @path/to/file │
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
findWordEndInLine,
|
findWordEndInLine,
|
||||||
findNextWordStartInLine,
|
findNextWordStartInLine,
|
||||||
isWordCharStrict,
|
isWordCharStrict,
|
||||||
|
calculateTransformationsForLine,
|
||||||
|
calculateTransformedLine,
|
||||||
|
getTransformUnderCursor,
|
||||||
|
getTransformedImagePath,
|
||||||
} from './text-buffer.js';
|
} from './text-buffer.js';
|
||||||
import { cpLen } from '../../utils/textUtils.js';
|
import { cpLen } from '../../utils/textUtils.js';
|
||||||
|
|
||||||
@@ -31,6 +35,8 @@ const defaultVisualLayout: VisualLayout = {
|
|||||||
visualLines: [''],
|
visualLines: [''],
|
||||||
logicalToVisualMap: [[[0, 0]]],
|
logicalToVisualMap: [[[0, 0]]],
|
||||||
visualToLogicalMap: [[0, 0]],
|
visualToLogicalMap: [[0, 0]],
|
||||||
|
transformedToLogicalMaps: [[]],
|
||||||
|
visualToTransformedMap: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: TextBufferState = {
|
const initialState: TextBufferState = {
|
||||||
@@ -44,6 +50,7 @@ const initialState: TextBufferState = {
|
|||||||
selectionAnchor: null,
|
selectionAnchor: null,
|
||||||
viewportWidth: 80,
|
viewportWidth: 80,
|
||||||
viewportHeight: 24,
|
viewportHeight: 24,
|
||||||
|
transformationsByLine: [[]],
|
||||||
visualLayout: defaultVisualLayout,
|
visualLayout: defaultVisualLayout,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2381,3 +2388,182 @@ describe('Unicode helper functions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Transformation Utilities', () => {
|
||||||
|
describe('getTransformedImagePath', () => {
|
||||||
|
it('should transform a simple image path', () => {
|
||||||
|
expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle paths with directories', () => {
|
||||||
|
expect(getTransformedImagePath('@path/to/image.jpg')).toBe(
|
||||||
|
'[Image image.jpg]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate long filenames', () => {
|
||||||
|
expect(getTransformedImagePath('@verylongfilename1234567890.png')).toBe(
|
||||||
|
'[Image ...1234567890.png]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different image extensions', () => {
|
||||||
|
expect(getTransformedImagePath('@test.jpg')).toBe('[Image test.jpg]');
|
||||||
|
expect(getTransformedImagePath('@test.jpeg')).toBe('[Image test.jpeg]');
|
||||||
|
expect(getTransformedImagePath('@test.gif')).toBe('[Image test.gif]');
|
||||||
|
expect(getTransformedImagePath('@test.webp')).toBe('[Image test.webp]');
|
||||||
|
expect(getTransformedImagePath('@test.svg')).toBe('[Image test.svg]');
|
||||||
|
expect(getTransformedImagePath('@test.bmp')).toBe('[Image test.bmp]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle POSIX-style forward-slash paths on any platform', () => {
|
||||||
|
const input = '@C:/Users/foo/screenshots/image2x.png';
|
||||||
|
expect(getTransformedImagePath(input)).toBe('[Image image2x.png]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Windows-style backslash paths on any platform', () => {
|
||||||
|
const input = '@C:\\Users\\foo\\screenshots\\image2x.png';
|
||||||
|
expect(getTransformedImagePath(input)).toBe('[Image image2x.png]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle escaped spaces in paths', () => {
|
||||||
|
const input = '@path/to/my\\ file.png';
|
||||||
|
expect(getTransformedImagePath(input)).toBe('[Image my file.png]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTransformationsForLine', () => {
|
||||||
|
it('should find transformations in a line', () => {
|
||||||
|
const line = 'Check out @test.png and @another.jpg';
|
||||||
|
const result = calculateTransformationsForLine(line);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
logicalText: '@test.png',
|
||||||
|
collapsedText: '[Image test.png]',
|
||||||
|
});
|
||||||
|
expect(result[1]).toMatchObject({
|
||||||
|
logicalText: '@another.jpg',
|
||||||
|
collapsedText: '[Image another.jpg]',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no transformations', () => {
|
||||||
|
const line = 'Just some regular text';
|
||||||
|
const result = calculateTransformationsForLine(line);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty line', () => {
|
||||||
|
const result = calculateTransformationsForLine('');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep adjacent image paths as separate transformations', () => {
|
||||||
|
const line = '@a.png@b.png@c.png';
|
||||||
|
const result = calculateTransformationsForLine(line);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0].logicalText).toBe('@a.png');
|
||||||
|
expect(result[1].logicalText).toBe('@b.png');
|
||||||
|
expect(result[2].logicalText).toBe('@c.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple transformations in a row', () => {
|
||||||
|
const line = '@a.png @b.png @c.png';
|
||||||
|
const result = calculateTransformationsForLine(line);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTransformUnderCursor', () => {
|
||||||
|
const transformations = [
|
||||||
|
{
|
||||||
|
logStart: 5,
|
||||||
|
logEnd: 14,
|
||||||
|
logicalText: '@test.png',
|
||||||
|
collapsedText: '[Image @test.png]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
logStart: 20,
|
||||||
|
logEnd: 31,
|
||||||
|
logicalText: '@another.jpg',
|
||||||
|
collapsedText: '[Image @another.jpg]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should find transformation when cursor is inside it', () => {
|
||||||
|
const result = getTransformUnderCursor(0, 7, [transformations]);
|
||||||
|
expect(result).toEqual(transformations[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find transformation when cursor is at start', () => {
|
||||||
|
const result = getTransformUnderCursor(0, 5, [transformations]);
|
||||||
|
expect(result).toEqual(transformations[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find transformation when cursor is at end', () => {
|
||||||
|
const result = getTransformUnderCursor(0, 14, [transformations]);
|
||||||
|
expect(result).toEqual(transformations[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when cursor is not on a transformation', () => {
|
||||||
|
const result = getTransformUnderCursor(0, 2, [transformations]);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty transformations array', () => {
|
||||||
|
const result = getTransformUnderCursor(0, 5, []);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateTransformedLine', () => {
|
||||||
|
it('should transform a line with one transformation', () => {
|
||||||
|
const line = 'Check out @test.png';
|
||||||
|
const transformations = calculateTransformationsForLine(line);
|
||||||
|
const result = calculateTransformedLine(line, 0, [0, 0], transformations);
|
||||||
|
|
||||||
|
expect(result.transformedLine).toBe('Check out [Image test.png]');
|
||||||
|
expect(result.transformedToLogMap).toHaveLength(27); // Length includes all characters in the transformed line
|
||||||
|
|
||||||
|
// Test that we have proper mappings
|
||||||
|
expect(result.transformedToLogMap[0]).toBe(0); // 'C'
|
||||||
|
expect(result.transformedToLogMap[9]).toBe(9); // ' ' before transformation
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cursor inside transformation', () => {
|
||||||
|
const line = 'Check out @test.png';
|
||||||
|
const transformations = calculateTransformationsForLine(line);
|
||||||
|
// Cursor at '@' (position 10 in the line)
|
||||||
|
const result = calculateTransformedLine(
|
||||||
|
line,
|
||||||
|
0,
|
||||||
|
[0, 10],
|
||||||
|
transformations,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show full path when cursor is on it
|
||||||
|
expect(result.transformedLine).toBe('Check out @test.png');
|
||||||
|
// When expanded, each character maps to itself
|
||||||
|
expect(result.transformedToLogMap[10]).toBe(10); // '@'
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle line with no transformations', () => {
|
||||||
|
const line = 'Just some text';
|
||||||
|
const result = calculateTransformedLine(line, 0, [0, 0], []);
|
||||||
|
|
||||||
|
expect(result.transformedLine).toBe(line);
|
||||||
|
// Each visual position should map directly to logical position + trailing
|
||||||
|
expect(result.transformedToLogMap).toHaveLength(15); // 14 chars + 1 trailing
|
||||||
|
expect(result.transformedToLogMap[0]).toBe(0);
|
||||||
|
expect(result.transformedToLogMap[13]).toBe(13);
|
||||||
|
expect(result.transformedToLogMap[14]).toBe(14); // Trailing position
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty line', () => {
|
||||||
|
const result = calculateTransformedLine('', 0, [0, 0], []);
|
||||||
|
expect(result.transformedLine).toBe('');
|
||||||
|
expect(result.transformedToLogMap).toEqual([0]); // Just the trailing position
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { spawnSync } from 'node:child_process';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import pathMod from 'node:path';
|
import pathMod from 'node:path';
|
||||||
|
import * as path from 'node:path';
|
||||||
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
|
import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
|
||||||
import { coreEvents, CoreEvent } from '@google/gemini-cli-core';
|
import { coreEvents, CoreEvent, unescapePath } from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
toCodePoints,
|
toCodePoints,
|
||||||
cpLen,
|
cpLen,
|
||||||
@@ -669,13 +670,182 @@ export function logicalPosToOffset(
|
|||||||
|
|
||||||
return offset;
|
return offset;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Transformations allow for the CLI to render terse representations of things like file paths
|
||||||
|
* (e.g., "@some/path/to/an/image.png" to "[Image image.png]")
|
||||||
|
* When the cursor enters a transformed representation, it expands to reveal the logical representation.
|
||||||
|
* (e.g., "[Image image.png]" to "@some/path/to/an/image.png")
|
||||||
|
*/
|
||||||
|
export interface Transformation {
|
||||||
|
logStart: number;
|
||||||
|
logEnd: number;
|
||||||
|
logicalText: string;
|
||||||
|
collapsedText: string;
|
||||||
|
}
|
||||||
|
export const imagePathRegex =
|
||||||
|
/@((?:\\.|[^\s\r\n\\])+?\.(?:png|jpg|jpeg|gif|webp|svg|bmp))\b/gi;
|
||||||
|
|
||||||
|
export function getTransformedImagePath(filePath: string): string {
|
||||||
|
const raw = filePath;
|
||||||
|
|
||||||
|
// Ignore leading @ when stripping directories, but keep it for simple '@file.png'
|
||||||
|
const withoutAt = raw.startsWith('@') ? raw.slice(1) : raw;
|
||||||
|
|
||||||
|
// Unescape the path to handle escaped spaces and other characters
|
||||||
|
const unescaped = unescapePath(withoutAt);
|
||||||
|
|
||||||
|
// Find last directory separator, supporting both POSIX and Windows styles
|
||||||
|
const lastSepIndex = Math.max(
|
||||||
|
unescaped.lastIndexOf('/'),
|
||||||
|
unescaped.lastIndexOf('\\'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we saw a separator, take the segment after it; otherwise fall back to the unescaped string
|
||||||
|
const fileName =
|
||||||
|
lastSepIndex >= 0 ? unescaped.slice(lastSepIndex + 1) : unescaped;
|
||||||
|
|
||||||
|
const extension = path.extname(fileName);
|
||||||
|
const baseName = path.basename(fileName, extension);
|
||||||
|
const maxBaseLength = 10;
|
||||||
|
|
||||||
|
const truncatedBase =
|
||||||
|
baseName.length > maxBaseLength
|
||||||
|
? `...${baseName.slice(-maxBaseLength)}`
|
||||||
|
: baseName;
|
||||||
|
|
||||||
|
return `[Image ${truncatedBase}${extension}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateTransformationsForLine(
|
||||||
|
line: string,
|
||||||
|
): Transformation[] {
|
||||||
|
const transformations: Transformation[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
// Reset regex state to ensure clean matching from start of line
|
||||||
|
imagePathRegex.lastIndex = 0;
|
||||||
|
|
||||||
|
while ((match = imagePathRegex.exec(line)) !== null) {
|
||||||
|
const logicalText = match[0];
|
||||||
|
const logStart = cpLen(line.substring(0, match.index));
|
||||||
|
const logEnd = logStart + cpLen(logicalText);
|
||||||
|
|
||||||
|
transformations.push({
|
||||||
|
logStart,
|
||||||
|
logEnd,
|
||||||
|
logicalText,
|
||||||
|
collapsedText: getTransformedImagePath(logicalText),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateTransformations(lines: string[]): Transformation[][] {
|
||||||
|
return lines.map((ln) => calculateTransformationsForLine(ln));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTransformUnderCursor(
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
spansByLine: Transformation[][],
|
||||||
|
): Transformation | null {
|
||||||
|
const spans = spansByLine[row];
|
||||||
|
if (!spans || spans.length === 0) return null;
|
||||||
|
for (const span of spans) {
|
||||||
|
if (col >= span.logStart && col <= span.logEnd) {
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
if (col < span.logStart) break;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateTransformedLine(
|
||||||
|
logLine: string,
|
||||||
|
logIndex: number,
|
||||||
|
logicalCursor: [number, number],
|
||||||
|
transformations: Transformation[],
|
||||||
|
): { transformedLine: string; transformedToLogMap: number[] } {
|
||||||
|
let transformedLine = '';
|
||||||
|
const transformedToLogMap: number[] = [];
|
||||||
|
let lastLogPos = 0;
|
||||||
|
|
||||||
|
const cursorIsOnThisLine = logIndex === logicalCursor[0];
|
||||||
|
const cursorCol = logicalCursor[1];
|
||||||
|
|
||||||
|
for (const transform of transformations) {
|
||||||
|
const textBeforeTransformation = cpSlice(
|
||||||
|
logLine,
|
||||||
|
lastLogPos,
|
||||||
|
transform.logStart,
|
||||||
|
);
|
||||||
|
transformedLine += textBeforeTransformation;
|
||||||
|
for (let i = 0; i < cpLen(textBeforeTransformation); i++) {
|
||||||
|
transformedToLogMap.push(lastLogPos + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpanded =
|
||||||
|
cursorIsOnThisLine &&
|
||||||
|
cursorCol >= transform.logStart &&
|
||||||
|
cursorCol <= transform.logEnd;
|
||||||
|
const transformedText = isExpanded
|
||||||
|
? transform.logicalText
|
||||||
|
: transform.collapsedText;
|
||||||
|
transformedLine += transformedText;
|
||||||
|
|
||||||
|
// Map transformed characters back to logical characters
|
||||||
|
const transformedLen = cpLen(transformedText);
|
||||||
|
if (isExpanded) {
|
||||||
|
for (let i = 0; i < transformedLen; i++) {
|
||||||
|
transformedToLogMap.push(transform.logStart + i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Collapsed: distribute transformed positions monotonically across the raw span.
|
||||||
|
// This preserves ordering across wrapped slices so logicalToVisualMap has
|
||||||
|
// increasing startColInLogical and visual cursor mapping remains consistent.
|
||||||
|
const logicalLength = Math.max(0, transform.logEnd - transform.logStart);
|
||||||
|
for (let i = 0; i < transformedLen; i++) {
|
||||||
|
// Map the i-th transformed code point into [logStart, logEnd)
|
||||||
|
const transformationToLogicalOffset =
|
||||||
|
logicalLength === 0
|
||||||
|
? 0
|
||||||
|
: Math.floor((i * logicalLength) / transformedLen);
|
||||||
|
const transformationToLogicalIndex =
|
||||||
|
transform.logStart +
|
||||||
|
Math.min(
|
||||||
|
transformationToLogicalOffset,
|
||||||
|
Math.max(logicalLength - 1, 0),
|
||||||
|
);
|
||||||
|
transformedToLogMap.push(transformationToLogicalIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastLogPos = transform.logEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append text after last transform
|
||||||
|
const remainingUntransformedText = cpSlice(logLine, lastLogPos);
|
||||||
|
transformedLine += remainingUntransformedText;
|
||||||
|
for (let i = 0; i < cpLen(remainingUntransformedText); i++) {
|
||||||
|
transformedToLogMap.push(lastLogPos + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a cursor at the very end of the transformed line
|
||||||
|
transformedToLogMap.push(cpLen(logLine));
|
||||||
|
|
||||||
|
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]
|
||||||
logicalToVisualMap: Array<Array<[number, number]>>;
|
logicalToVisualMap: Array<Array<[number, number]>>;
|
||||||
// For each visual line, its [logicalLineIndex, startColInLogical]
|
// For each visual line, its [logicalLineIndex, startColInLogical]
|
||||||
visualToLogicalMap: Array<[number, number]>;
|
visualToLogicalMap: Array<[number, number]>;
|
||||||
|
// Image paths are transformed (e.g., "@some/path/to/an/image.png" to "[Image image.png]")
|
||||||
|
// For each logical line, an array that maps each transformedCol to a logicalCol
|
||||||
|
transformedToLogicalMaps: number[][];
|
||||||
|
// For each visual line, its [startColInTransformed]
|
||||||
|
visualToTransformedMap: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -683,22 +853,34 @@ export interface VisualLayout {
|
|||||||
function calculateLayout(
|
function calculateLayout(
|
||||||
logicalLines: string[],
|
logicalLines: string[],
|
||||||
viewportWidth: number,
|
viewportWidth: number,
|
||||||
|
logicalCursor: [number, number],
|
||||||
): VisualLayout {
|
): VisualLayout {
|
||||||
const visualLines: string[] = [];
|
const visualLines: string[] = [];
|
||||||
const logicalToVisualMap: Array<Array<[number, number]>> = [];
|
const logicalToVisualMap: Array<Array<[number, number]>> = [];
|
||||||
const visualToLogicalMap: Array<[number, number]> = [];
|
const visualToLogicalMap: Array<[number, number]> = [];
|
||||||
|
const transformedToLogicalMaps: number[][] = [];
|
||||||
|
const visualToTransformedMap: number[] = [];
|
||||||
|
|
||||||
logicalLines.forEach((logLine, logIndex) => {
|
logicalLines.forEach((logLine, logIndex) => {
|
||||||
logicalToVisualMap[logIndex] = [];
|
logicalToVisualMap[logIndex] = [];
|
||||||
if (logLine.length === 0) {
|
const transformations = calculateTransformationsForLine(logLine);
|
||||||
|
const { transformedLine, transformedToLogMap } = calculateTransformedLine(
|
||||||
|
logLine,
|
||||||
|
logIndex,
|
||||||
|
logicalCursor,
|
||||||
|
transformations,
|
||||||
|
);
|
||||||
|
transformedToLogicalMaps[logIndex] = transformedToLogMap;
|
||||||
|
if (transformedLine.length === 0) {
|
||||||
// Handle empty logical line
|
// Handle empty logical line
|
||||||
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
|
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
|
||||||
visualToLogicalMap.push([logIndex, 0]);
|
visualToLogicalMap.push([logIndex, 0]);
|
||||||
|
visualToTransformedMap.push(0);
|
||||||
visualLines.push('');
|
visualLines.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)
|
||||||
const codePointsInLogLine = toCodePoints(logLine);
|
const codePointsInLogLine = toCodePoints(transformedLine);
|
||||||
|
|
||||||
while (currentPosInLogLine < codePointsInLogLine.length) {
|
while (currentPosInLogLine < codePointsInLogLine.length) {
|
||||||
let currentChunk = '';
|
let currentChunk = '';
|
||||||
@@ -787,11 +969,13 @@ function calculateLayout(
|
|||||||
numCodePointsInChunk = 1;
|
numCodePointsInChunk = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logicalStartCol = transformedToLogMap[currentPosInLogLine] ?? 0;
|
||||||
logicalToVisualMap[logIndex].push([
|
logicalToVisualMap[logIndex].push([
|
||||||
visualLines.length,
|
visualLines.length,
|
||||||
currentPosInLogLine,
|
logicalStartCol,
|
||||||
]);
|
]);
|
||||||
visualToLogicalMap.push([logIndex, currentPosInLogLine]);
|
visualToLogicalMap.push([logIndex, logicalStartCol]);
|
||||||
|
visualToTransformedMap.push(currentPosInLogLine);
|
||||||
visualLines.push(currentChunk);
|
visualLines.push(currentChunk);
|
||||||
|
|
||||||
const logicalStartOfThisChunk = currentPosInLogLine;
|
const logicalStartOfThisChunk = currentPosInLogLine;
|
||||||
@@ -822,6 +1006,7 @@ function calculateLayout(
|
|||||||
if (!logicalToVisualMap[0]) logicalToVisualMap[0] = [];
|
if (!logicalToVisualMap[0]) logicalToVisualMap[0] = [];
|
||||||
logicalToVisualMap[0].push([0, 0]);
|
logicalToVisualMap[0].push([0, 0]);
|
||||||
visualToLogicalMap.push([0, 0]);
|
visualToLogicalMap.push([0, 0]);
|
||||||
|
visualToTransformedMap.push(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,6 +1014,8 @@ function calculateLayout(
|
|||||||
visualLines,
|
visualLines,
|
||||||
logicalToVisualMap,
|
logicalToVisualMap,
|
||||||
visualToLogicalMap,
|
visualToLogicalMap,
|
||||||
|
transformedToLogicalMaps,
|
||||||
|
visualToTransformedMap,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,7 +1025,7 @@ function calculateVisualCursorFromLayout(
|
|||||||
layout: VisualLayout,
|
layout: VisualLayout,
|
||||||
logicalCursor: [number, number],
|
logicalCursor: [number, number],
|
||||||
): [number, number] {
|
): [number, number] {
|
||||||
const { logicalToVisualMap, visualLines } = layout;
|
const { logicalToVisualMap, visualLines, transformedToLogicalMaps } = layout;
|
||||||
const [logicalRow, logicalCol] = logicalCursor;
|
const [logicalRow, logicalCol] = logicalCursor;
|
||||||
|
|
||||||
const segmentsForLogicalLine = logicalToVisualMap[logicalRow];
|
const segmentsForLogicalLine = logicalToVisualMap[logicalRow];
|
||||||
@@ -873,14 +1060,35 @@ function calculateVisualCursorFromLayout(
|
|||||||
|
|
||||||
const [visualRow, startColInLogical] =
|
const [visualRow, startColInLogical] =
|
||||||
segmentsForLogicalLine[targetSegmentIndex];
|
segmentsForLogicalLine[targetSegmentIndex];
|
||||||
const visualCol = logicalCol - startColInLogical;
|
|
||||||
|
|
||||||
// The visual column should not exceed the length of the visual line.
|
// Find the coordinates in transformed space in order to conver to visual
|
||||||
|
const transformedToLogicalMap = transformedToLogicalMaps[logicalRow] ?? [];
|
||||||
|
let transformedCol = 0;
|
||||||
|
for (let i = 0; i < transformedToLogicalMap.length; i++) {
|
||||||
|
if (transformedToLogicalMap[i] > logicalCol) {
|
||||||
|
transformedCol = Math.max(0, i - 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (i === transformedToLogicalMap.length - 1) {
|
||||||
|
transformedCol = transformedToLogicalMap.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let startColInTransformed = 0;
|
||||||
|
while (
|
||||||
|
startColInTransformed < transformedToLogicalMap.length &&
|
||||||
|
transformedToLogicalMap[startColInTransformed] < startColInLogical
|
||||||
|
) {
|
||||||
|
startColInTransformed++;
|
||||||
|
}
|
||||||
|
const clampedTransformedCol = Math.min(
|
||||||
|
transformedCol,
|
||||||
|
Math.max(0, transformedToLogicalMap.length - 1),
|
||||||
|
);
|
||||||
|
const visualCol = clampedTransformedCol - startColInTransformed;
|
||||||
const clampedVisualCol = Math.min(
|
const clampedVisualCol = Math.min(
|
||||||
visualCol,
|
Math.max(visualCol, 0),
|
||||||
cpLen(visualLines[visualRow] ?? ''),
|
cpLen(visualLines[visualRow] ?? ''),
|
||||||
);
|
);
|
||||||
|
|
||||||
return [visualRow, clampedVisualCol];
|
return [visualRow, clampedVisualCol];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,6 +1098,7 @@ export interface TextBufferState {
|
|||||||
lines: string[];
|
lines: string[];
|
||||||
cursorRow: number;
|
cursorRow: number;
|
||||||
cursorCol: number;
|
cursorCol: number;
|
||||||
|
transformationsByLine: Transformation[][];
|
||||||
preferredCol: number | null; // This is the logical character offset in the visual line
|
preferredCol: number | null; // This is the logical character offset in the visual line
|
||||||
undoStack: UndoHistoryEntry[];
|
undoStack: UndoHistoryEntry[];
|
||||||
redoStack: UndoHistoryEntry[];
|
redoStack: UndoHistoryEntry[];
|
||||||
@@ -1210,15 +1419,27 @@ function textBufferReducerLogic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (visualToLogicalMap[newVisualRow]) {
|
if (visualToLogicalMap[newVisualRow]) {
|
||||||
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
|
const [logRow, logicalStartCol] = visualToLogicalMap[newVisualRow];
|
||||||
|
const transformedToLogicalMap =
|
||||||
|
visualLayout.transformedToLogicalMaps?.[logRow] ?? [];
|
||||||
|
let transformedStartCol = 0;
|
||||||
|
while (
|
||||||
|
transformedStartCol < transformedToLogicalMap.length &&
|
||||||
|
transformedToLogicalMap[transformedStartCol] < logicalStartCol
|
||||||
|
) {
|
||||||
|
transformedStartCol++;
|
||||||
|
}
|
||||||
|
const clampedTransformedCol = Math.min(
|
||||||
|
transformedStartCol + newVisualCol,
|
||||||
|
Math.max(0, transformedToLogicalMap.length - 1),
|
||||||
|
);
|
||||||
|
const newLogicalCol =
|
||||||
|
transformedToLogicalMap[clampedTransformedCol] ??
|
||||||
|
cpLen(lines[logRow] ?? '');
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
cursorRow: logRow,
|
cursorRow: logRow,
|
||||||
cursorCol: clamp(
|
cursorCol: newLogicalCol,
|
||||||
logStartCol + newVisualCol,
|
|
||||||
0,
|
|
||||||
cpLen(lines[logRow] ?? ''),
|
|
||||||
),
|
|
||||||
preferredCol: newPreferredCol,
|
preferredCol: newPreferredCol,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1543,13 +1764,43 @@ export function textBufferReducer(
|
|||||||
): TextBufferState {
|
): TextBufferState {
|
||||||
const newState = textBufferReducerLogic(state, action, options);
|
const newState = textBufferReducerLogic(state, action, options);
|
||||||
|
|
||||||
|
const newTransformedLines =
|
||||||
|
newState.lines !== state.lines
|
||||||
|
? calculateTransformations(newState.lines)
|
||||||
|
: state.transformationsByLine;
|
||||||
|
|
||||||
|
const oldTransform = getTransformUnderCursor(
|
||||||
|
state.cursorRow,
|
||||||
|
state.cursorCol,
|
||||||
|
state.transformationsByLine,
|
||||||
|
);
|
||||||
|
const newTransform = getTransformUnderCursor(
|
||||||
|
newState.cursorRow,
|
||||||
|
newState.cursorCol,
|
||||||
|
newTransformedLines,
|
||||||
|
);
|
||||||
|
const oldInside = oldTransform !== null;
|
||||||
|
const newInside = newTransform !== null;
|
||||||
|
const movedBetweenTransforms =
|
||||||
|
oldTransform !== newTransform &&
|
||||||
|
(oldTransform !== null || newTransform !== null);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
newState.lines !== state.lines ||
|
newState.lines !== state.lines ||
|
||||||
newState.viewportWidth !== state.viewportWidth
|
newState.viewportWidth !== state.viewportWidth ||
|
||||||
|
oldInside !== newInside ||
|
||||||
|
movedBetweenTransforms
|
||||||
) {
|
) {
|
||||||
|
const shouldResetPreferred =
|
||||||
|
oldInside !== newInside || movedBetweenTransforms;
|
||||||
return {
|
return {
|
||||||
...newState,
|
...newState,
|
||||||
visualLayout: calculateLayout(newState.lines, newState.viewportWidth),
|
preferredCol: shouldResetPreferred ? null : newState.preferredCol,
|
||||||
|
visualLayout: calculateLayout(newState.lines, newState.viewportWidth, [
|
||||||
|
newState.cursorRow,
|
||||||
|
newState.cursorCol,
|
||||||
|
]),
|
||||||
|
transformationsByLine: newTransformedLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1576,14 +1827,19 @@ export function useTextBuffer({
|
|||||||
lines.length === 0 ? [''] : lines,
|
lines.length === 0 ? [''] : lines,
|
||||||
initialCursorOffset,
|
initialCursorOffset,
|
||||||
);
|
);
|
||||||
|
const transformationsByLine = calculateTransformations(
|
||||||
|
lines.length === 0 ? [''] : lines,
|
||||||
|
);
|
||||||
const visualLayout = calculateLayout(
|
const visualLayout = calculateLayout(
|
||||||
lines.length === 0 ? [''] : lines,
|
lines.length === 0 ? [''] : lines,
|
||||||
viewport.width,
|
viewport.width,
|
||||||
|
[initialCursorRow, initialCursorCol],
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
lines: lines.length === 0 ? [''] : lines,
|
lines: lines.length === 0 ? [''] : lines,
|
||||||
cursorRow: initialCursorRow,
|
cursorRow: initialCursorRow,
|
||||||
cursorCol: initialCursorCol,
|
cursorCol: initialCursorCol,
|
||||||
|
transformationsByLine,
|
||||||
preferredCol: null,
|
preferredCol: null,
|
||||||
undoStack: [],
|
undoStack: [],
|
||||||
redoStack: [],
|
redoStack: [],
|
||||||
@@ -1607,6 +1863,7 @@ export function useTextBuffer({
|
|||||||
preferredCol,
|
preferredCol,
|
||||||
selectionAnchor,
|
selectionAnchor,
|
||||||
visualLayout,
|
visualLayout,
|
||||||
|
transformationsByLine,
|
||||||
} = state;
|
} = state;
|
||||||
|
|
||||||
const text = useMemo(() => lines.join('\n'), [lines]);
|
const text = useMemo(() => lines.join('\n'), [lines]);
|
||||||
@@ -1616,7 +1873,12 @@ export function useTextBuffer({
|
|||||||
[visualLayout, cursorRow, cursorCol],
|
[visualLayout, cursorRow, cursorCol],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { visualLines, visualToLogicalMap } = visualLayout;
|
const {
|
||||||
|
visualLines,
|
||||||
|
visualToLogicalMap,
|
||||||
|
transformedToLogicalMaps,
|
||||||
|
visualToTransformedMap,
|
||||||
|
} = visualLayout;
|
||||||
|
|
||||||
const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
|
const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
|
||||||
|
|
||||||
@@ -2035,7 +2297,12 @@ export function useTextBuffer({
|
|||||||
|
|
||||||
const moveToVisualPosition = useCallback(
|
const moveToVisualPosition = useCallback(
|
||||||
(visRow: number, visCol: number): void => {
|
(visRow: number, visCol: number): void => {
|
||||||
const { visualLines, visualToLogicalMap } = visualLayout;
|
const {
|
||||||
|
visualLines,
|
||||||
|
visualToLogicalMap,
|
||||||
|
transformedToLogicalMaps,
|
||||||
|
visualToTransformedMap,
|
||||||
|
} = visualLayout;
|
||||||
// Clamp visRow to valid range
|
// Clamp visRow to valid range
|
||||||
const clampedVisRow = Math.max(
|
const clampedVisRow = Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -2044,8 +2311,15 @@ export function useTextBuffer({
|
|||||||
const visualLine = visualLines[clampedVisRow] || '';
|
const visualLine = visualLines[clampedVisRow] || '';
|
||||||
|
|
||||||
if (visualToLogicalMap[clampedVisRow]) {
|
if (visualToLogicalMap[clampedVisRow]) {
|
||||||
const [logRow, logStartCol] = visualToLogicalMap[clampedVisRow];
|
const [logRow] = visualToLogicalMap[clampedVisRow];
|
||||||
|
const transformedToLogicalMap =
|
||||||
|
transformedToLogicalMaps?.[logRow] ?? [];
|
||||||
|
|
||||||
|
// Where does this visual line begin within the transformed line?
|
||||||
|
const startColInTransformed =
|
||||||
|
visualToTransformedMap?.[clampedVisRow] ?? 0;
|
||||||
|
|
||||||
|
// Handle wide characters: convert visual X position to character offset
|
||||||
const codePoints = toCodePoints(visualLine);
|
const codePoints = toCodePoints(visualLine);
|
||||||
let currentVisX = 0;
|
let currentVisX = 0;
|
||||||
let charOffset = 0;
|
let charOffset = 0;
|
||||||
@@ -2067,8 +2341,15 @@ export function useTextBuffer({
|
|||||||
// Clamp charOffset to length
|
// Clamp charOffset to length
|
||||||
charOffset = Math.min(charOffset, codePoints.length);
|
charOffset = Math.min(charOffset, codePoints.length);
|
||||||
|
|
||||||
|
// Map character offset through transformations to get logical position
|
||||||
|
const transformedCol = Math.min(
|
||||||
|
startColInTransformed + charOffset,
|
||||||
|
Math.max(0, transformedToLogicalMap.length - 1),
|
||||||
|
);
|
||||||
|
|
||||||
const newCursorRow = logRow;
|
const newCursorRow = logRow;
|
||||||
const newCursorCol = logStartCol + charOffset;
|
const newCursorCol =
|
||||||
|
transformedToLogicalMap[transformedCol] ?? cpLen(lines[logRow] ?? '');
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'set_cursor',
|
type: 'set_cursor',
|
||||||
@@ -2080,7 +2361,7 @@ export function useTextBuffer({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[visualLayout],
|
[visualLayout, lines],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getOffset = useCallback(
|
const getOffset = useCallback(
|
||||||
@@ -2101,7 +2382,9 @@ export function useTextBuffer({
|
|||||||
visualCursor,
|
visualCursor,
|
||||||
visualScrollRow,
|
visualScrollRow,
|
||||||
visualToLogicalMap,
|
visualToLogicalMap,
|
||||||
|
transformedToLogicalMaps,
|
||||||
|
visualToTransformedMap,
|
||||||
|
transformationsByLine,
|
||||||
setText,
|
setText,
|
||||||
insert,
|
insert,
|
||||||
newline,
|
newline,
|
||||||
@@ -2167,6 +2450,10 @@ export function useTextBuffer({
|
|||||||
renderedVisualLines,
|
renderedVisualLines,
|
||||||
visualCursor,
|
visualCursor,
|
||||||
visualScrollRow,
|
visualScrollRow,
|
||||||
|
visualToLogicalMap,
|
||||||
|
transformedToLogicalMaps,
|
||||||
|
visualToTransformedMap,
|
||||||
|
transformationsByLine,
|
||||||
setText,
|
setText,
|
||||||
insert,
|
insert,
|
||||||
newline,
|
newline,
|
||||||
@@ -2218,7 +2505,6 @@ export function useTextBuffer({
|
|||||||
vimMoveToLastLine,
|
vimMoveToLastLine,
|
||||||
vimMoveToLine,
|
vimMoveToLine,
|
||||||
vimEscapeInsertMode,
|
vimEscapeInsertMode,
|
||||||
visualToLogicalMap,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return returnValue;
|
return returnValue;
|
||||||
@@ -2249,6 +2535,18 @@ export interface TextBuffer {
|
|||||||
* begins within the logical buffer. Indices are code-point based.
|
* begins within the logical buffer. Indices are code-point based.
|
||||||
*/
|
*/
|
||||||
visualToLogicalMap: Array<[number, number]>;
|
visualToLogicalMap: Array<[number, number]>;
|
||||||
|
/**
|
||||||
|
* For each logical line, an array mapping transformed positions (in the transformed
|
||||||
|
* line) back to logical column indices.
|
||||||
|
*/
|
||||||
|
transformedToLogicalMaps: number[][];
|
||||||
|
/**
|
||||||
|
* For each visual line (absolute index across all visual lines), the start index
|
||||||
|
* within that logical line's transformed content.
|
||||||
|
*/
|
||||||
|
visualToTransformedMap: number[];
|
||||||
|
/** Cached transformations per logical line */
|
||||||
|
transformationsByLine: Transformation[][];
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const defaultVisualLayout: VisualLayout = {
|
|||||||
visualLines: [''],
|
visualLines: [''],
|
||||||
logicalToVisualMap: [[[0, 0]]],
|
logicalToVisualMap: [[[0, 0]]],
|
||||||
visualToLogicalMap: [[0, 0]],
|
visualToLogicalMap: [[0, 0]],
|
||||||
|
transformedToLogicalMaps: [[]],
|
||||||
|
visualToTransformedMap: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to create test state
|
// Helper to create test state
|
||||||
@@ -30,6 +32,7 @@ const createTestState = (
|
|||||||
selectionAnchor: null,
|
selectionAnchor: null,
|
||||||
viewportWidth: 80,
|
viewportWidth: 80,
|
||||||
viewportHeight: 24,
|
viewportHeight: 24,
|
||||||
|
transformationsByLine: [[]],
|
||||||
visualLayout: defaultVisualLayout,
|
visualLayout: defaultVisualLayout,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,15 +59,17 @@ const createMockTextBufferState = (
|
|||||||
selectionAnchor: null,
|
selectionAnchor: null,
|
||||||
viewportWidth: 80,
|
viewportWidth: 80,
|
||||||
viewportHeight: 24,
|
viewportHeight: 24,
|
||||||
|
transformationsByLine: lines.map(() => []),
|
||||||
visualLayout: {
|
visualLayout: {
|
||||||
visualLines: lines,
|
visualLines: lines,
|
||||||
logicalToVisualMap: lines.map((_, i) => [[i, 0]]),
|
logicalToVisualMap: lines.map((_, i) => [[i, 0]]),
|
||||||
visualToLogicalMap: lines.map((_, i) => [i, 0]),
|
visualToLogicalMap: lines.map((_, i) => [i, 0]),
|
||||||
|
transformedToLogicalMaps: lines.map(() => []),
|
||||||
|
visualToTransformedMap: [],
|
||||||
},
|
},
|
||||||
...partial,
|
...partial,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test constants
|
// Test constants
|
||||||
const TEST_SEQUENCES = {
|
const TEST_SEQUENCES = {
|
||||||
ESCAPE: createKey({ sequence: '\u001b', name: 'escape' }),
|
ESCAPE: createKey({ sequence: '\u001b', name: 'escape' }),
|
||||||
@@ -174,6 +176,10 @@ describe('useVim hook', () => {
|
|||||||
cursorState.pos = [row, col - 1];
|
cursorState.pos = [row, col - 1];
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
// Additional properties for transformations
|
||||||
|
transformedToLogicalMaps: lines.map(() => []),
|
||||||
|
visualToTransformedMap: [],
|
||||||
|
transformationsByLine: lines.map(() => []),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -134,3 +134,103 @@ describe('parseInputForHighlighting', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseInputForHighlighting with Transformations', () => {
|
||||||
|
const transformations = [
|
||||||
|
{
|
||||||
|
logStart: 10,
|
||||||
|
logEnd: 19,
|
||||||
|
logicalText: '@test.png',
|
||||||
|
collapsedText: '[Image test.png]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('should show collapsed transformation when cursor is not on it', () => {
|
||||||
|
const line = 'Check out @test.png';
|
||||||
|
const result = parseInputForHighlighting(
|
||||||
|
line,
|
||||||
|
0, // line index
|
||||||
|
transformations,
|
||||||
|
0, // cursor not on transformation
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: 'Check out ', type: 'default' },
|
||||||
|
{ text: '[Image test.png]', type: 'file' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show expanded transformation when cursor is on it', () => {
|
||||||
|
const line = 'Check out @test.png';
|
||||||
|
const result = parseInputForHighlighting(
|
||||||
|
line,
|
||||||
|
0, // line index
|
||||||
|
transformations,
|
||||||
|
11, // cursor on transformation
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: 'Check out ', type: 'default' },
|
||||||
|
{ text: '@test.png', type: 'file' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple transformations in a line', () => {
|
||||||
|
const line = 'Images: @test1.png and @test2.png';
|
||||||
|
const multiTransformations = [
|
||||||
|
{
|
||||||
|
logStart: 8,
|
||||||
|
logEnd: 18,
|
||||||
|
logicalText: '@test1.png',
|
||||||
|
collapsedText: '[Image test1.png]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
logStart: 23,
|
||||||
|
logEnd: 33,
|
||||||
|
logicalText: '@test2.png',
|
||||||
|
collapsedText: '[Image test2.png]',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Cursor not on any transformation
|
||||||
|
let result = parseInputForHighlighting(line, 0, multiTransformations, 0);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: 'Images: ', type: 'default' },
|
||||||
|
{ text: '[Image test1.png]', type: 'file' },
|
||||||
|
{ text: ' and ', type: 'default' },
|
||||||
|
{ text: '[Image test2.png]', type: 'file' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Cursor on first transformation
|
||||||
|
result = parseInputForHighlighting(line, 0, multiTransformations, 10);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: 'Images: ', type: 'default' },
|
||||||
|
{ text: '@test1.png', type: 'file' },
|
||||||
|
{ text: ' and ', type: 'default' },
|
||||||
|
{ text: '[Image test2.png]', type: 'file' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty transformations array', () => {
|
||||||
|
const line = 'Check out @test.png';
|
||||||
|
const result = parseInputForHighlighting(line, 0, [], 0);
|
||||||
|
|
||||||
|
// Should fall back to default highlighting
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ text: 'Check out ', type: 'default' },
|
||||||
|
{ text: '@test.png', type: 'file' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cursor at transformation boundaries', () => {
|
||||||
|
const line = 'Check out @test.png';
|
||||||
|
const result = parseInputForHighlighting(
|
||||||
|
line,
|
||||||
|
0,
|
||||||
|
transformations,
|
||||||
|
10, // cursor at start of transformation
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[1]).toEqual({ text: '@test.png', type: 'file' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Transformation } from '../components/shared/text-buffer.js';
|
||||||
import { cpLen, cpSlice } from './textUtils.js';
|
import { cpLen, cpSlice } from './textUtils.js';
|
||||||
|
|
||||||
export type HighlightToken = {
|
export type HighlightToken = {
|
||||||
@@ -20,57 +21,81 @@ const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[^,\s;!?()[\]{}])+)/g;
|
|||||||
export function parseInputForHighlighting(
|
export function parseInputForHighlighting(
|
||||||
text: string,
|
text: string,
|
||||||
index: number,
|
index: number,
|
||||||
|
transformations: Transformation[] = [],
|
||||||
|
cursorCol?: number,
|
||||||
): readonly HighlightToken[] {
|
): readonly HighlightToken[] {
|
||||||
|
HIGHLIGHT_REGEX.lastIndex = 0;
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return [{ text: '', type: 'default' }];
|
return [{ text: '', type: 'default' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseUntransformedInput = (text: string): HighlightToken[] => {
|
||||||
const tokens: HighlightToken[] = [];
|
const tokens: HighlightToken[] = [];
|
||||||
let lastIndex = 0;
|
if (!text) return tokens;
|
||||||
let match;
|
|
||||||
|
HIGHLIGHT_REGEX.lastIndex = 0;
|
||||||
|
let last = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = HIGHLIGHT_REGEX.exec(text)) !== null) {
|
while ((match = HIGHLIGHT_REGEX.exec(text)) !== null) {
|
||||||
const [fullMatch] = match;
|
const [fullMatch] = match;
|
||||||
const matchIndex = match.index;
|
const matchIndex = match.index;
|
||||||
|
|
||||||
// Add the text before the match as a default token
|
if (matchIndex > last) {
|
||||||
if (matchIndex > lastIndex) {
|
tokens.push({ text: text.slice(last, matchIndex), type: 'default' });
|
||||||
tokens.push({
|
|
||||||
text: text.slice(lastIndex, matchIndex),
|
|
||||||
type: 'default',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the matched token
|
|
||||||
const type = fullMatch.startsWith('/') ? 'command' : 'file';
|
const type = fullMatch.startsWith('/') ? 'command' : 'file';
|
||||||
// Only highlight slash commands if the index is 0.
|
|
||||||
if (type === 'command' && index !== 0) {
|
if (type === 'command' && index !== 0) {
|
||||||
tokens.push({
|
tokens.push({ text: fullMatch, type: 'default' });
|
||||||
text: fullMatch,
|
|
||||||
type: 'default',
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
tokens.push({
|
tokens.push({ text: fullMatch, type });
|
||||||
text: fullMatch,
|
|
||||||
type,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastIndex = matchIndex + fullMatch.length;
|
last = matchIndex + fullMatch.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any remaining text after the last match
|
if (last < text.length) {
|
||||||
if (lastIndex < text.length) {
|
tokens.push({ text: text.slice(last), type: 'default' });
|
||||||
tokens.push({
|
|
||||||
text: text.slice(lastIndex),
|
|
||||||
type: 'default',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tokens;
|
return tokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokens: HighlightToken[] = [];
|
||||||
|
|
||||||
|
let column = 0;
|
||||||
|
const sortedTransformations = (transformations ?? [])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.logStart - b.logStart);
|
||||||
|
|
||||||
|
for (const transformation of sortedTransformations) {
|
||||||
|
const textBeforeTransformation = cpSlice(
|
||||||
|
text,
|
||||||
|
column,
|
||||||
|
transformation.logStart,
|
||||||
|
);
|
||||||
|
tokens.push(...parseUntransformedInput(textBeforeTransformation));
|
||||||
|
|
||||||
|
const isCursorInside =
|
||||||
|
typeof cursorCol === 'number' &&
|
||||||
|
cursorCol >= transformation.logStart &&
|
||||||
|
cursorCol <= transformation.logEnd;
|
||||||
|
const transformationText = isCursorInside
|
||||||
|
? transformation.logicalText
|
||||||
|
: transformation.collapsedText;
|
||||||
|
tokens.push({ text: transformationText, type: 'file' });
|
||||||
|
|
||||||
|
column = transformation.logEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSegmentsForVisualSlice(
|
const textAfterFinalTransformation = cpSlice(text, column);
|
||||||
|
tokens.push(...parseUntransformedInput(textAfterFinalTransformation));
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSegmentsFromTokens(
|
||||||
tokens: readonly HighlightToken[],
|
tokens: readonly HighlightToken[],
|
||||||
sliceStart: number,
|
sliceStart: number,
|
||||||
sliceEnd: number,
|
sliceEnd: number,
|
||||||
@@ -102,6 +127,5 @@ export function buildSegmentsForVisualSlice(
|
|||||||
|
|
||||||
tokenCpStart += tokenLen;
|
tokenCpStart += tokenLen;
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user