mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-22 11:04:42 -07:00
feat: replace large text pastes with [Pasted Text: X lines] placeholder (#16422)
This commit is contained in:
@@ -176,6 +176,8 @@ describe('InputPrompt', () => {
|
||||
visualToTransformedMap: [0],
|
||||
transformationsByLine: [],
|
||||
getOffset: vi.fn().mockReturnValue(0),
|
||||
pastedContent: {},
|
||||
addPastedContent: vi.fn().mockReturnValue('[Pasted Text: 6 lines]'),
|
||||
} as unknown as TextBuffer;
|
||||
|
||||
mockShellHistory = {
|
||||
@@ -248,6 +250,8 @@ describe('InputPrompt', () => {
|
||||
checking: false,
|
||||
});
|
||||
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('');
|
||||
|
||||
props = {
|
||||
buffer: mockBuffer,
|
||||
onSubmit: vi.fn(),
|
||||
@@ -632,10 +636,9 @@ describe('InputPrompt', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(clipboardy.read).toHaveBeenCalled();
|
||||
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(
|
||||
'pasted text',
|
||||
expect.objectContaining({ paste: true }),
|
||||
);
|
||||
});
|
||||
unmount();
|
||||
@@ -1718,6 +1721,99 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('large paste placeholder', () => {
|
||||
it('should handle large clipboard paste (lines > 5) by calling buffer.insert', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
const largeText = '1\n2\n3\n4\n5\n6';
|
||||
vi.mocked(clipboardy.read).mockResolvedValue(largeText);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(
|
||||
largeText,
|
||||
expect.objectContaining({ paste: true }),
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle large clipboard paste (chars > 500) by calling buffer.insert', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
const largeText = 'a'.repeat(501);
|
||||
vi.mocked(clipboardy.read).mockResolvedValue(largeText);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(
|
||||
largeText,
|
||||
expect.objectContaining({ paste: true }),
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should handle normal clipboard paste by calling buffer.insert', async () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
const smallText = 'hello world';
|
||||
vi.mocked(clipboardy.read).mockResolvedValue(smallText);
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\x16'); // Ctrl+V
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(
|
||||
smallText,
|
||||
expect.objectContaining({ paste: true }),
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should replace placeholder with actual content on submit', async () => {
|
||||
// Setup buffer to have the placeholder
|
||||
const largeText = '1\n2\n3\n4\n5\n6';
|
||||
const id = '[Pasted Text: 6 lines]';
|
||||
mockBuffer.text = `Check this: ${id}`;
|
||||
mockBuffer.pastedContent = { [id]: largeText };
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('\r'); // Enter
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(`Check this: ${largeText}`);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('paste auto-submission protection', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -12,7 +12,10 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import type { TextBuffer } from './shared/text-buffer.js';
|
||||
import { logicalPosToOffset } from './shared/text-buffer.js';
|
||||
import {
|
||||
logicalPosToOffset,
|
||||
PASTED_TEXT_PLACEHOLDER_REGEX,
|
||||
} from './shared/text-buffer.js';
|
||||
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
|
||||
import chalk from 'chalk';
|
||||
import stringWidth from 'string-width';
|
||||
@@ -221,13 +224,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleSubmitAndClear = useCallback(
|
||||
(submittedValue: string) => {
|
||||
let processedValue = submittedValue;
|
||||
if (buffer.pastedContent) {
|
||||
// Replace placeholders like [Pasted Text: 6 lines] with actual content
|
||||
processedValue = processedValue.replace(
|
||||
PASTED_TEXT_PLACEHOLDER_REGEX,
|
||||
(match) => buffer.pastedContent[match] || match,
|
||||
);
|
||||
}
|
||||
|
||||
if (shellModeActive) {
|
||||
shellHistory.addCommandToHistory(submittedValue);
|
||||
shellHistory.addCommandToHistory(processedValue);
|
||||
}
|
||||
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
|
||||
// if onSubmit triggers a re-render while the buffer still holds the old value.
|
||||
buffer.setText('');
|
||||
onSubmit(submittedValue);
|
||||
onSubmit(processedValue);
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
},
|
||||
@@ -360,8 +372,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
stdout.write('\x1b]52;c;?\x07');
|
||||
} else {
|
||||
const textToInsert = await clipboardy.read();
|
||||
const offset = buffer.getOffset();
|
||||
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||
buffer.insert(textToInsert, { paste: true });
|
||||
}
|
||||
} catch (error) {
|
||||
debugLogger.error('Error handling paste:', error);
|
||||
@@ -1191,7 +1202,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
const color =
|
||||
seg.type === 'command' || seg.type === 'file'
|
||||
seg.type === 'command' ||
|
||||
seg.type === 'file' ||
|
||||
seg.type === 'paste'
|
||||
? theme.text.accent
|
||||
: theme.text.primary;
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ const initialState: TextBufferState = {
|
||||
viewportHeight: 24,
|
||||
transformationsByLine: [[]],
|
||||
visualLayout: defaultVisualLayout,
|
||||
pastedContent: {},
|
||||
};
|
||||
|
||||
describe('textBufferReducer', () => {
|
||||
@@ -153,6 +154,19 @@ describe('textBufferReducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('add_pasted_content action', () => {
|
||||
it('should add content to pastedContent Record', () => {
|
||||
const action: TextBufferAction = {
|
||||
type: 'add_pasted_content',
|
||||
payload: { id: '[Pasted Text: 6 lines]', text: 'large content' },
|
||||
};
|
||||
const state = textBufferReducer(initialState, action);
|
||||
expect(state.pastedContent).toEqual({
|
||||
'[Pasted Text: 6 lines]': 'large content',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('backspace action', () => {
|
||||
it('should remove a character', () => {
|
||||
const stateWithText: TextBufferState = {
|
||||
@@ -184,6 +198,155 @@ describe('textBufferReducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('atomic placeholder deletion', () => {
|
||||
describe('paste placeholders', () => {
|
||||
it('backspace at end of paste placeholder removes entire placeholder', () => {
|
||||
const placeholder = '[Pasted Text: 6 lines]';
|
||||
const stateWithPlaceholder: TextBufferState = {
|
||||
...initialState,
|
||||
lines: [placeholder],
|
||||
cursorRow: 0,
|
||||
cursorCol: placeholder.length, // cursor at end
|
||||
pastedContent: {
|
||||
[placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6',
|
||||
},
|
||||
};
|
||||
const action: TextBufferAction = { type: 'backspace' };
|
||||
const state = textBufferReducer(stateWithPlaceholder, action);
|
||||
expect(state).toHaveOnlyValidCharacters();
|
||||
expect(state.lines).toEqual(['']);
|
||||
expect(state.cursorCol).toBe(0);
|
||||
// pastedContent should be cleaned up
|
||||
expect(state.pastedContent[placeholder]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('delete at start of paste placeholder removes entire placeholder', () => {
|
||||
const placeholder = '[Pasted Text: 6 lines]';
|
||||
const stateWithPlaceholder: TextBufferState = {
|
||||
...initialState,
|
||||
lines: [placeholder],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0, // cursor at start
|
||||
pastedContent: {
|
||||
[placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6',
|
||||
},
|
||||
};
|
||||
const action: TextBufferAction = { type: 'delete' };
|
||||
const state = textBufferReducer(stateWithPlaceholder, action);
|
||||
expect(state).toHaveOnlyValidCharacters();
|
||||
expect(state.lines).toEqual(['']);
|
||||
expect(state.cursorCol).toBe(0);
|
||||
// pastedContent should be cleaned up
|
||||
expect(state.pastedContent[placeholder]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('backspace inside paste placeholder does normal deletion', () => {
|
||||
const placeholder = '[Pasted Text: 6 lines]';
|
||||
const stateWithPlaceholder: TextBufferState = {
|
||||
...initialState,
|
||||
lines: [placeholder],
|
||||
cursorRow: 0,
|
||||
cursorCol: 10, // cursor in middle
|
||||
pastedContent: {
|
||||
[placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6',
|
||||
},
|
||||
};
|
||||
const action: TextBufferAction = { type: 'backspace' };
|
||||
const state = textBufferReducer(stateWithPlaceholder, action);
|
||||
expect(state).toHaveOnlyValidCharacters();
|
||||
// Should only delete one character
|
||||
expect(state.lines[0].length).toBe(placeholder.length - 1);
|
||||
expect(state.cursorCol).toBe(9);
|
||||
// pastedContent should NOT be cleaned up (placeholder is broken)
|
||||
expect(state.pastedContent[placeholder]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('image placeholders', () => {
|
||||
it('backspace at end of image path removes entire path', () => {
|
||||
const imagePath = '@test.png';
|
||||
const transformations = calculateTransformationsForLine(imagePath);
|
||||
const stateWithImage: TextBufferState = {
|
||||
...initialState,
|
||||
lines: [imagePath],
|
||||
cursorRow: 0,
|
||||
cursorCol: imagePath.length, // cursor at end
|
||||
transformationsByLine: [transformations],
|
||||
};
|
||||
const action: TextBufferAction = { type: 'backspace' };
|
||||
const state = textBufferReducer(stateWithImage, action);
|
||||
expect(state).toHaveOnlyValidCharacters();
|
||||
expect(state.lines).toEqual(['']);
|
||||
expect(state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('delete at start of image path removes entire path', () => {
|
||||
const imagePath = '@test.png';
|
||||
const transformations = calculateTransformationsForLine(imagePath);
|
||||
const stateWithImage: TextBufferState = {
|
||||
...initialState,
|
||||
lines: [imagePath],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0, // cursor at start
|
||||
transformationsByLine: [transformations],
|
||||
};
|
||||
const action: TextBufferAction = { type: 'delete' };
|
||||
const state = textBufferReducer(stateWithImage, action);
|
||||
expect(state).toHaveOnlyValidCharacters();
|
||||
expect(state.lines).toEqual(['']);
|
||||
expect(state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('backspace inside image path does normal deletion', () => {
|
||||
const imagePath = '@test.png';
|
||||
const transformations = calculateTransformationsForLine(imagePath);
|
||||
const stateWithImage: TextBufferState = {
|
||||
...initialState,
|
||||
lines: [imagePath],
|
||||
cursorRow: 0,
|
||||
cursorCol: 5, // cursor in middle
|
||||
transformationsByLine: [transformations],
|
||||
};
|
||||
const action: TextBufferAction = { type: 'backspace' };
|
||||
const state = textBufferReducer(stateWithImage, action);
|
||||
expect(state).toHaveOnlyValidCharacters();
|
||||
// Should only delete one character
|
||||
expect(state.lines[0].length).toBe(imagePath.length - 1);
|
||||
expect(state.cursorCol).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo behavior', () => {
|
||||
it('undo after placeholder deletion restores everything', () => {
|
||||
const placeholder = '[Pasted Text: 6 lines]';
|
||||
const pasteContent = 'line1\nline2\nline3\nline4\nline5\nline6';
|
||||
const stateWithPlaceholder: TextBufferState = {
|
||||
...initialState,
|
||||
lines: [placeholder],
|
||||
cursorRow: 0,
|
||||
cursorCol: placeholder.length,
|
||||
pastedContent: { [placeholder]: pasteContent },
|
||||
};
|
||||
|
||||
// Delete the placeholder
|
||||
const deleteAction: TextBufferAction = { type: 'backspace' };
|
||||
const stateAfterDelete = textBufferReducer(
|
||||
stateWithPlaceholder,
|
||||
deleteAction,
|
||||
);
|
||||
expect(stateAfterDelete.lines).toEqual(['']);
|
||||
expect(stateAfterDelete.pastedContent[placeholder]).toBeUndefined();
|
||||
|
||||
// Undo should restore
|
||||
const undoAction: TextBufferAction = { type: 'undo' };
|
||||
const stateAfterUndo = textBufferReducer(stateAfterDelete, undoAction);
|
||||
expect(stateAfterUndo).toHaveOnlyValidCharacters();
|
||||
expect(stateAfterUndo.lines).toEqual([placeholder]);
|
||||
expect(stateAfterUndo.pastedContent[placeholder]).toBe(pasteContent);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('undo/redo actions', () => {
|
||||
it('should undo and redo a change', () => {
|
||||
// 1. Insert text
|
||||
@@ -548,6 +711,64 @@ describe('useTextBuffer', () => {
|
||||
expect(state.cursor).toEqual([0, 6]);
|
||||
});
|
||||
|
||||
it('insert: should use placeholder for large text paste', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const largeText = '1\n2\n3\n4\n5\n6';
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
const state = getBufferState(result);
|
||||
expect(state.text).toBe('[Pasted Text: 6 lines]');
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
});
|
||||
|
||||
it('insert: should NOT use placeholder for large text if NOT a paste', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const largeText = '1\n2\n3\n4\n5\n6';
|
||||
act(() => result.current.insert(largeText, { paste: false }));
|
||||
const state = getBufferState(result);
|
||||
expect(state.text).toBe(largeText);
|
||||
});
|
||||
|
||||
it('insert: should clean up pastedContent when placeholder is deleted', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const largeText = '1\n2\n3\n4\n5\n6';
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
|
||||
// Delete the placeholder using setText
|
||||
act(() => result.current.setText(''));
|
||||
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('insert: should clean up pastedContent when placeholder is removed via atomic backspace', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const largeText = '1\n2\n3\n4\n5\n6';
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
|
||||
// Single backspace at end of placeholder removes entire placeholder
|
||||
act(() => {
|
||||
result.current.backspace();
|
||||
});
|
||||
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
// pastedContent is cleaned up when placeholder is deleted atomically
|
||||
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('newline: should create a new line and move cursor', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
@@ -1350,9 +1571,14 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||
});
|
||||
|
||||
const state = getBufferState(result);
|
||||
// Check that the text is the result of three concatenations.
|
||||
expect(state.lines).toStrictEqual(
|
||||
(longText + longText + longText).split('\n'),
|
||||
// Check that the text is the result of three concatenations of placeholders.
|
||||
// All three use the same placeholder because React batches the state updates
|
||||
// within the same act() block, so pastedContent isn't updated between inserts.
|
||||
expect(state.lines).toStrictEqual([
|
||||
'[Pasted Text: 8 lines][Pasted Text: 8 lines][Pasted Text: 8 lines]',
|
||||
]);
|
||||
expect(result.current.pastedContent['[Pasted Text: 8 lines]']).toBe(
|
||||
longText,
|
||||
);
|
||||
const expectedCursorPos = offsetToLogicalPos(
|
||||
state.text,
|
||||
|
||||
@@ -34,6 +34,13 @@ import type { VimAction } from './vim-buffer-actions.js';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
|
||||
|
||||
const LARGE_PASTE_LINE_THRESHOLD = 5;
|
||||
const LARGE_PASTE_CHAR_THRESHOLD = 500;
|
||||
|
||||
// Regex to match paste placeholders like [Pasted Text: 6 lines] or [Pasted Text: 501 chars #2]
|
||||
export const PASTED_TEXT_PLACEHOLDER_REGEX =
|
||||
/\[Pasted Text: \d+ (?:lines|chars)(?: #\d+)?\]/g;
|
||||
|
||||
export type Direction =
|
||||
| 'left'
|
||||
| 'right'
|
||||
@@ -578,6 +585,7 @@ interface UndoHistoryEntry {
|
||||
lines: string[];
|
||||
cursorRow: number;
|
||||
cursorCol: number;
|
||||
pastedContent: Record<string, string>;
|
||||
}
|
||||
|
||||
function calculateInitialCursorPosition(
|
||||
@@ -784,6 +792,88 @@ export function getTransformUnderCursor(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an atomic placeholder that should be deleted as a unit.
|
||||
* Extensible to support future placeholder types.
|
||||
*/
|
||||
interface AtomicPlaceholder {
|
||||
start: number; // Start position in logical text
|
||||
end: number; // End position in logical text
|
||||
type: 'paste' | 'image'; // Type for cleanup logic
|
||||
id?: string; // For paste placeholders: the pastedContent key
|
||||
}
|
||||
|
||||
/**
|
||||
* Find atomic placeholder at cursor for backspace (cursor at end).
|
||||
* Checks all placeholder types in priority order.
|
||||
*/
|
||||
function findAtomicPlaceholderForBackspace(
|
||||
line: string,
|
||||
cursorCol: number,
|
||||
transformations: Transformation[],
|
||||
): AtomicPlaceholder | null {
|
||||
// 1. Check paste placeholders (text-based)
|
||||
const pasteRegex = new RegExp(PASTED_TEXT_PLACEHOLDER_REGEX.source, 'g');
|
||||
let match;
|
||||
while ((match = pasteRegex.exec(line)) !== null) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
if (cursorCol === end) {
|
||||
return { start, end, type: 'paste', id: match[0] };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check image transformations (logical bounds)
|
||||
for (const transform of transformations) {
|
||||
if (cursorCol === transform.logEnd) {
|
||||
return {
|
||||
start: transform.logStart,
|
||||
end: transform.logEnd,
|
||||
type: 'image',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find atomic placeholder at cursor for delete (cursor at start).
|
||||
*/
|
||||
function findAtomicPlaceholderForDelete(
|
||||
line: string,
|
||||
cursorCol: number,
|
||||
transformations: Transformation[],
|
||||
): AtomicPlaceholder | null {
|
||||
// 1. Check paste placeholders
|
||||
const pasteRegex = new RegExp(PASTED_TEXT_PLACEHOLDER_REGEX.source, 'g');
|
||||
let match;
|
||||
while ((match = pasteRegex.exec(line)) !== null) {
|
||||
const start = match.index;
|
||||
if (cursorCol === start) {
|
||||
return {
|
||||
start,
|
||||
end: start + match[0].length,
|
||||
type: 'paste',
|
||||
id: match[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check image transformations
|
||||
for (const transform of transformations) {
|
||||
if (cursorCol === transform.logStart) {
|
||||
return {
|
||||
start: transform.logStart,
|
||||
end: transform.logEnd,
|
||||
type: 'image',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function calculateTransformedLine(
|
||||
logLine: string,
|
||||
logIndex: number,
|
||||
@@ -1184,6 +1274,7 @@ export interface TextBufferState {
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
visualLayout: VisualLayout;
|
||||
pastedContent: Record<string, string>;
|
||||
}
|
||||
|
||||
const historyLimit = 100;
|
||||
@@ -1193,6 +1284,7 @@ export const pushUndo = (currentState: TextBufferState): TextBufferState => {
|
||||
lines: [...currentState.lines],
|
||||
cursorRow: currentState.cursorRow,
|
||||
cursorCol: currentState.cursorCol,
|
||||
pastedContent: { ...currentState.pastedContent },
|
||||
};
|
||||
const newStack = [...currentState.undoStack, snapshot];
|
||||
if (newStack.length > historyLimit) {
|
||||
@@ -1204,6 +1296,7 @@ export const pushUndo = (currentState: TextBufferState): TextBufferState => {
|
||||
export type TextBufferAction =
|
||||
| { type: 'set_text'; payload: string; pushToUndo?: boolean }
|
||||
| { type: 'insert'; payload: string }
|
||||
| { type: 'add_pasted_content'; payload: { id: string; text: string } }
|
||||
| { type: 'backspace' }
|
||||
| {
|
||||
type: 'move';
|
||||
@@ -1308,6 +1401,7 @@ function textBufferReducerLogic(
|
||||
cursorRow: lastNewLineIndex,
|
||||
cursorCol: cpLen(lines[lastNewLineIndex] ?? ''),
|
||||
preferredCol: null,
|
||||
pastedContent: action.payload === '' ? {} : nextState.pastedContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1365,7 +1459,62 @@ function textBufferReducerLogic(
|
||||
};
|
||||
}
|
||||
|
||||
case 'add_pasted_content': {
|
||||
const { id, text } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
pastedContent: {
|
||||
...state.pastedContent,
|
||||
[id]: text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'backspace': {
|
||||
const { cursorRow, cursorCol, lines, transformationsByLine } = state;
|
||||
|
||||
// Early return if at start of buffer
|
||||
if (cursorCol === 0 && cursorRow === 0) return state;
|
||||
|
||||
// Check if cursor is at end of an atomic placeholder
|
||||
const transformations = transformationsByLine[cursorRow] ?? [];
|
||||
const placeholder = findAtomicPlaceholderForBackspace(
|
||||
lines[cursorRow],
|
||||
cursorCol,
|
||||
transformations,
|
||||
);
|
||||
|
||||
if (placeholder) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] =
|
||||
cpSlice(newLines[cursorRow], 0, placeholder.start) +
|
||||
cpSlice(newLines[cursorRow], placeholder.end);
|
||||
|
||||
// Recalculate transformations for the modified line
|
||||
const newTransformations = [...nextState.transformationsByLine];
|
||||
newTransformations[cursorRow] = calculateTransformationsForLine(
|
||||
newLines[cursorRow],
|
||||
);
|
||||
|
||||
// Clean up pastedContent if this was a paste placeholder
|
||||
let newPastedContent = nextState.pastedContent;
|
||||
if (placeholder.type === 'paste' && placeholder.id) {
|
||||
const { [placeholder.id]: _, ...remaining } = nextState.pastedContent;
|
||||
newPastedContent = remaining;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorCol: placeholder.start,
|
||||
preferredCol: null,
|
||||
transformationsByLine: newTransformations,
|
||||
pastedContent: newPastedContent,
|
||||
};
|
||||
}
|
||||
|
||||
// Standard backspace logic
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
let newCursorRow = nextState.cursorRow;
|
||||
@@ -1373,8 +1522,6 @@ function textBufferReducerLogic(
|
||||
|
||||
const currentLine = (r: number) => newLines[r] ?? '';
|
||||
|
||||
if (newCursorCol === 0 && newCursorRow === 0) return state;
|
||||
|
||||
if (newCursorCol > 0) {
|
||||
const lineContent = currentLine(newCursorRow);
|
||||
newLines[newCursorRow] =
|
||||
@@ -1584,7 +1731,47 @@ function textBufferReducerLogic(
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const { cursorRow, cursorCol, lines } = state;
|
||||
const { cursorRow, cursorCol, lines, transformationsByLine } = state;
|
||||
|
||||
// Check if cursor is at start of an atomic placeholder
|
||||
const transformations = transformationsByLine[cursorRow] ?? [];
|
||||
const placeholder = findAtomicPlaceholderForDelete(
|
||||
lines[cursorRow],
|
||||
cursorCol,
|
||||
transformations,
|
||||
);
|
||||
|
||||
if (placeholder) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
const newLines = [...nextState.lines];
|
||||
newLines[cursorRow] =
|
||||
cpSlice(newLines[cursorRow], 0, placeholder.start) +
|
||||
cpSlice(newLines[cursorRow], placeholder.end);
|
||||
|
||||
// Recalculate transformations for the modified line
|
||||
const newTransformations = [...nextState.transformationsByLine];
|
||||
newTransformations[cursorRow] = calculateTransformationsForLine(
|
||||
newLines[cursorRow],
|
||||
);
|
||||
|
||||
// Clean up pastedContent if this was a paste placeholder
|
||||
let newPastedContent = nextState.pastedContent;
|
||||
if (placeholder.type === 'paste' && placeholder.id) {
|
||||
const { [placeholder.id]: _, ...remaining } = nextState.pastedContent;
|
||||
newPastedContent = remaining;
|
||||
}
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
// cursorCol stays the same
|
||||
preferredCol: null,
|
||||
transformationsByLine: newTransformations,
|
||||
pastedContent: newPastedContent,
|
||||
};
|
||||
}
|
||||
|
||||
// Standard delete logic
|
||||
const lineContent = currentLine(cursorRow);
|
||||
if (cursorCol < currentLineLen(cursorRow)) {
|
||||
const nextState = pushUndoLocal(state);
|
||||
@@ -1734,6 +1921,7 @@ function textBufferReducerLogic(
|
||||
lines: [...state.lines],
|
||||
cursorRow: state.cursorRow,
|
||||
cursorCol: state.cursorCol,
|
||||
pastedContent: { ...state.pastedContent },
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
@@ -1751,6 +1939,7 @@ function textBufferReducerLogic(
|
||||
lines: [...state.lines],
|
||||
cursorRow: state.cursorRow,
|
||||
cursorCol: state.cursorCol,
|
||||
pastedContent: { ...state.pastedContent },
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
@@ -1926,6 +2115,7 @@ export function useTextBuffer({
|
||||
viewportWidth: viewport.width,
|
||||
viewportHeight: viewport.height,
|
||||
visualLayout,
|
||||
pastedContent: {},
|
||||
};
|
||||
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
|
||||
|
||||
@@ -1942,6 +2132,7 @@ export function useTextBuffer({
|
||||
selectionAnchor,
|
||||
visualLayout,
|
||||
transformationsByLine,
|
||||
pastedContent,
|
||||
} = state;
|
||||
|
||||
const text = useMemo(() => lines.join('\n'), [lines]);
|
||||
@@ -1995,20 +2186,64 @@ export function useTextBuffer({
|
||||
}
|
||||
}, [visualCursor, visualScrollRow, viewport, visualLines.length]);
|
||||
|
||||
const addPastedContent = useCallback(
|
||||
(content: string, lineCount: number): string => {
|
||||
// content is already normalized by the caller
|
||||
const base =
|
||||
lineCount > LARGE_PASTE_LINE_THRESHOLD
|
||||
? `[Pasted Text: ${lineCount} lines]`
|
||||
: `[Pasted Text: ${content.length} chars]`;
|
||||
|
||||
let id = base;
|
||||
let suffix = 2;
|
||||
while (pastedContent[id]) {
|
||||
id = base.replace(']', ` #${suffix}]`);
|
||||
suffix++;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'add_pasted_content',
|
||||
payload: { id, text: content },
|
||||
});
|
||||
return id;
|
||||
},
|
||||
[pastedContent],
|
||||
);
|
||||
|
||||
const insert = useCallback(
|
||||
(ch: string, { paste = false }: { paste?: boolean } = {}): void => {
|
||||
if (!singleLine && /[\n\r]/.test(ch)) {
|
||||
dispatch({ type: 'insert', payload: ch });
|
||||
if (typeof ch !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize line endings once at the entry point for pastes
|
||||
const text = paste ? ch.replace(/\r\n|\r/g, '\n') : ch;
|
||||
|
||||
if (paste) {
|
||||
const lineCount = text.split('\n').length;
|
||||
if (
|
||||
lineCount > LARGE_PASTE_LINE_THRESHOLD ||
|
||||
text.length > LARGE_PASTE_CHAR_THRESHOLD
|
||||
) {
|
||||
const id = addPastedContent(text, lineCount);
|
||||
dispatch({ type: 'insert', payload: id });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!singleLine && /[\n\r]/.test(text)) {
|
||||
dispatch({ type: 'insert', payload: text });
|
||||
return;
|
||||
}
|
||||
|
||||
let textToInsert = text;
|
||||
const minLengthToInferAsDragDrop = 3;
|
||||
if (
|
||||
ch.length >= minLengthToInferAsDragDrop &&
|
||||
text.length >= minLengthToInferAsDragDrop &&
|
||||
!shellModeActive &&
|
||||
paste
|
||||
) {
|
||||
let potentialPath = ch.trim();
|
||||
let potentialPath = text.trim();
|
||||
const quoteMatch = potentialPath.match(/^'(.*)'$/);
|
||||
if (quoteMatch) {
|
||||
potentialPath = quoteMatch[1];
|
||||
@@ -2018,12 +2253,12 @@ export function useTextBuffer({
|
||||
|
||||
const processed = parsePastedPaths(potentialPath, isValidPath);
|
||||
if (processed) {
|
||||
ch = processed;
|
||||
textToInsert = processed;
|
||||
}
|
||||
}
|
||||
|
||||
let currentText = '';
|
||||
for (const char of toCodePoints(ch)) {
|
||||
for (const char of toCodePoints(textToInsert)) {
|
||||
if (char.codePointAt(0) === 127) {
|
||||
if (currentText.length > 0) {
|
||||
dispatch({ type: 'insert', payload: currentText });
|
||||
@@ -2038,7 +2273,7 @@ export function useTextBuffer({
|
||||
dispatch({ type: 'insert', payload: currentText });
|
||||
}
|
||||
},
|
||||
[isValidPath, shellModeActive, singleLine],
|
||||
[isValidPath, shellModeActive, singleLine, addPastedContent],
|
||||
);
|
||||
|
||||
const newline = useCallback((): void => {
|
||||
@@ -2435,6 +2670,7 @@ export function useTextBuffer({
|
||||
cursor: [cursorRow, cursorCol],
|
||||
preferredCol,
|
||||
selectionAnchor,
|
||||
pastedContent,
|
||||
|
||||
allVisualLines: visualLines,
|
||||
viewportVisualLines: renderedVisualLines,
|
||||
@@ -2447,6 +2683,7 @@ export function useTextBuffer({
|
||||
visualLayout,
|
||||
setText,
|
||||
insert,
|
||||
addPastedContent,
|
||||
newline,
|
||||
backspace,
|
||||
del,
|
||||
@@ -2506,6 +2743,7 @@ export function useTextBuffer({
|
||||
cursorCol,
|
||||
preferredCol,
|
||||
selectionAnchor,
|
||||
pastedContent,
|
||||
visualLines,
|
||||
renderedVisualLines,
|
||||
visualCursor,
|
||||
@@ -2517,6 +2755,7 @@ export function useTextBuffer({
|
||||
visualLayout,
|
||||
setText,
|
||||
insert,
|
||||
addPastedContent,
|
||||
newline,
|
||||
backspace,
|
||||
del,
|
||||
@@ -2584,6 +2823,7 @@ export interface TextBuffer {
|
||||
*/
|
||||
preferredCol: number | null; // Preferred visual column
|
||||
selectionAnchor: [number, number] | null; // Logical selection anchor
|
||||
pastedContent: Record<string, string>;
|
||||
|
||||
// Visual state (handles wrapping)
|
||||
allVisualLines: string[]; // All visual lines for the current text and viewport width.
|
||||
@@ -2621,6 +2861,7 @@ export interface TextBuffer {
|
||||
* Insert a single character or string without newlines.
|
||||
*/
|
||||
insert: (ch: string, opts?: { paste?: boolean }) => void;
|
||||
addPastedContent: (text: string, lineCount: number) => string;
|
||||
newline: () => void;
|
||||
backspace: () => void;
|
||||
del: () => void;
|
||||
|
||||
@@ -34,6 +34,7 @@ const createTestState = (
|
||||
viewportHeight: 24,
|
||||
transformationsByLine: [[]],
|
||||
visualLayout: defaultVisualLayout,
|
||||
pastedContent: {},
|
||||
});
|
||||
|
||||
describe('vim-buffer-actions', () => {
|
||||
@@ -904,7 +905,9 @@ describe('vim-buffer-actions', () => {
|
||||
|
||||
it('should preserve undo stack in operations', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }];
|
||||
state.undoStack = [
|
||||
{ lines: ['previous'], cursorRow: 0, cursorCol: 0, pastedContent: {} },
|
||||
];
|
||||
|
||||
const action = {
|
||||
type: 'vim_delete_char' as const,
|
||||
|
||||
@@ -67,6 +67,7 @@ const createMockTextBufferState = (
|
||||
transformedToLogicalMaps: lines.map(() => []),
|
||||
visualToTransformedMap: [],
|
||||
},
|
||||
pastedContent: {},
|
||||
...partial,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,21 +4,28 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type Transformation,
|
||||
PASTED_TEXT_PLACEHOLDER_REGEX,
|
||||
} from '../components/shared/text-buffer.js';
|
||||
import { LRUCache } from 'mnemonist';
|
||||
import type { Transformation } from '../components/shared/text-buffer.js';
|
||||
import { cpLen, cpSlice } from './textUtils.js';
|
||||
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js';
|
||||
|
||||
export type HighlightToken = {
|
||||
text: string;
|
||||
type: 'default' | 'command' | 'file';
|
||||
type: 'default' | 'command' | 'file' | 'paste';
|
||||
};
|
||||
|
||||
// Matches slash commands (e.g., /help) and @ references (files or MCP resource URIs).
|
||||
// Matches slash commands (e.g., /help), @ references (files or MCP resource URIs),
|
||||
// and large paste placeholders (e.g., [Pasted Text: 6 lines]).
|
||||
// The @ pattern uses a negated character class to support URIs like `@file:///example.txt`
|
||||
// which contain colons. It matches any character except delimiters: comma, whitespace,
|
||||
// semicolon, common punctuation, and brackets.
|
||||
const HIGHLIGHT_REGEX = /(^\/[a-zA-Z0-9_-]+|@(?:\\ |[^,\s;!?()[\]{}])+)/g;
|
||||
const HIGHLIGHT_REGEX = new RegExp(
|
||||
`(^/[a-zA-Z0-9_-]+|@(?:\\\\ |[^,\\s;!?()\\[\\]{}])+|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
|
||||
'g',
|
||||
);
|
||||
|
||||
const highlightCache = new LRUCache<string, readonly HighlightToken[]>(
|
||||
LRU_BUFFER_PERF_CACHE_LIMIT,
|
||||
@@ -66,7 +73,11 @@ export function parseInputForHighlighting(
|
||||
tokens.push({ text: text.slice(last, matchIndex), type: 'default' });
|
||||
}
|
||||
|
||||
const type = fullMatch.startsWith('/') ? 'command' : 'file';
|
||||
const type = fullMatch.startsWith('/')
|
||||
? 'command'
|
||||
: fullMatch.startsWith('@')
|
||||
? 'file'
|
||||
: 'paste';
|
||||
if (type === 'command' && index !== 0) {
|
||||
tokens.push({ text: fullMatch, type: 'default' });
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user