feat: replace large text pastes with [Pasted Text: X lines] placeholder (#16422)

This commit is contained in:
Jack Wotherspoon
2026-01-21 21:09:24 -05:00
committed by GitHub
parent 27d21f9921
commit 75e4f492ab
7 changed files with 619 additions and 28 deletions
@@ -176,6 +176,8 @@ describe('InputPrompt', () => {
visualToTransformedMap: [0], visualToTransformedMap: [0],
transformationsByLine: [], transformationsByLine: [],
getOffset: vi.fn().mockReturnValue(0), getOffset: vi.fn().mockReturnValue(0),
pastedContent: {},
addPastedContent: vi.fn().mockReturnValue('[Pasted Text: 6 lines]'),
} as unknown as TextBuffer; } as unknown as TextBuffer;
mockShellHistory = { mockShellHistory = {
@@ -248,6 +250,8 @@ describe('InputPrompt', () => {
checking: false, checking: false,
}); });
vi.mocked(clipboardy.read).mockResolvedValue('');
props = { props = {
buffer: mockBuffer, buffer: mockBuffer,
onSubmit: vi.fn(), onSubmit: vi.fn(),
@@ -632,10 +636,9 @@ describe('InputPrompt', () => {
await waitFor(() => { await waitFor(() => {
expect(clipboardy.read).toHaveBeenCalled(); expect(clipboardy.read).toHaveBeenCalled();
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalledWith( expect(mockBuffer.insert).toHaveBeenCalledWith(
expect.any(Number),
expect.any(Number),
'pasted text', 'pasted text',
expect.objectContaining({ paste: true }),
); );
}); });
unmount(); 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', () => { describe('paste auto-submission protection', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
+19 -6
View File
@@ -12,7 +12,10 @@ import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js'; import { useInputHistory } from '../hooks/useInputHistory.js';
import type { TextBuffer } from './shared/text-buffer.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 { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
import chalk from 'chalk'; import chalk from 'chalk';
import stringWidth from 'string-width'; import stringWidth from 'string-width';
@@ -221,13 +224,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleSubmitAndClear = useCallback( const handleSubmitAndClear = useCallback(
(submittedValue: string) => { (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) { if (shellModeActive) {
shellHistory.addCommandToHistory(submittedValue); shellHistory.addCommandToHistory(processedValue);
} }
// Clear the buffer *before* calling onSubmit to prevent potential re-submission // 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. // if onSubmit triggers a re-render while the buffer still holds the old value.
buffer.setText(''); buffer.setText('');
onSubmit(submittedValue); onSubmit(processedValue);
resetCompletionState(); resetCompletionState();
resetReverseSearchCompletionState(); resetReverseSearchCompletionState();
}, },
@@ -360,8 +372,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
stdout.write('\x1b]52;c;?\x07'); stdout.write('\x1b]52;c;?\x07');
} else { } else {
const textToInsert = await clipboardy.read(); const textToInsert = await clipboardy.read();
const offset = buffer.getOffset(); buffer.insert(textToInsert, { paste: true });
buffer.replaceRangeByOffset(offset, offset, textToInsert);
} }
} catch (error) { } catch (error) {
debugLogger.error('Error handling paste:', error); debugLogger.error('Error handling paste:', error);
@@ -1191,7 +1202,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
const color = const color =
seg.type === 'command' || seg.type === 'file' seg.type === 'command' ||
seg.type === 'file' ||
seg.type === 'paste'
? theme.text.accent ? theme.text.accent
: theme.text.primary; : theme.text.primary;
@@ -55,6 +55,7 @@ const initialState: TextBufferState = {
viewportHeight: 24, viewportHeight: 24,
transformationsByLine: [[]], transformationsByLine: [[]],
visualLayout: defaultVisualLayout, visualLayout: defaultVisualLayout,
pastedContent: {},
}; };
describe('textBufferReducer', () => { 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', () => { describe('backspace action', () => {
it('should remove a character', () => { it('should remove a character', () => {
const stateWithText: TextBufferState = { 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', () => { describe('undo/redo actions', () => {
it('should undo and redo a change', () => { it('should undo and redo a change', () => {
// 1. Insert text // 1. Insert text
@@ -548,6 +711,64 @@ describe('useTextBuffer', () => {
expect(state.cursor).toEqual([0, 6]); 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', () => { it('newline: should create a new line and move cursor', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useTextBuffer({ useTextBuffer({
@@ -1350,9 +1571,14 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
}); });
const state = getBufferState(result); const state = getBufferState(result);
// Check that the text is the result of three concatenations. // Check that the text is the result of three concatenations of placeholders.
expect(state.lines).toStrictEqual( // All three use the same placeholder because React batches the state updates
(longText + longText + longText).split('\n'), // 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( const expectedCursorPos = offsetToLogicalPos(
state.text, state.text,
@@ -34,6 +34,13 @@ 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'; 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 = export type Direction =
| 'left' | 'left'
| 'right' | 'right'
@@ -578,6 +585,7 @@ interface UndoHistoryEntry {
lines: string[]; lines: string[];
cursorRow: number; cursorRow: number;
cursorCol: number; cursorCol: number;
pastedContent: Record<string, string>;
} }
function calculateInitialCursorPosition( function calculateInitialCursorPosition(
@@ -784,6 +792,88 @@ export function getTransformUnderCursor(
return null; 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( export function calculateTransformedLine(
logLine: string, logLine: string,
logIndex: number, logIndex: number,
@@ -1184,6 +1274,7 @@ export interface TextBufferState {
viewportWidth: number; viewportWidth: number;
viewportHeight: number; viewportHeight: number;
visualLayout: VisualLayout; visualLayout: VisualLayout;
pastedContent: Record<string, string>;
} }
const historyLimit = 100; const historyLimit = 100;
@@ -1193,6 +1284,7 @@ export const pushUndo = (currentState: TextBufferState): TextBufferState => {
lines: [...currentState.lines], lines: [...currentState.lines],
cursorRow: currentState.cursorRow, cursorRow: currentState.cursorRow,
cursorCol: currentState.cursorCol, cursorCol: currentState.cursorCol,
pastedContent: { ...currentState.pastedContent },
}; };
const newStack = [...currentState.undoStack, snapshot]; const newStack = [...currentState.undoStack, snapshot];
if (newStack.length > historyLimit) { if (newStack.length > historyLimit) {
@@ -1204,6 +1296,7 @@ export const pushUndo = (currentState: TextBufferState): TextBufferState => {
export type TextBufferAction = export type TextBufferAction =
| { type: 'set_text'; payload: string; pushToUndo?: boolean } | { type: 'set_text'; payload: string; pushToUndo?: boolean }
| { type: 'insert'; payload: string } | { type: 'insert'; payload: string }
| { type: 'add_pasted_content'; payload: { id: string; text: string } }
| { type: 'backspace' } | { type: 'backspace' }
| { | {
type: 'move'; type: 'move';
@@ -1308,6 +1401,7 @@ function textBufferReducerLogic(
cursorRow: lastNewLineIndex, cursorRow: lastNewLineIndex,
cursorCol: cpLen(lines[lastNewLineIndex] ?? ''), cursorCol: cpLen(lines[lastNewLineIndex] ?? ''),
preferredCol: null, 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': { 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 nextState = pushUndoLocal(state);
const newLines = [...nextState.lines]; const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow; let newCursorRow = nextState.cursorRow;
@@ -1373,8 +1522,6 @@ function textBufferReducerLogic(
const currentLine = (r: number) => newLines[r] ?? ''; const currentLine = (r: number) => newLines[r] ?? '';
if (newCursorCol === 0 && newCursorRow === 0) return state;
if (newCursorCol > 0) { if (newCursorCol > 0) {
const lineContent = currentLine(newCursorRow); const lineContent = currentLine(newCursorRow);
newLines[newCursorRow] = newLines[newCursorRow] =
@@ -1584,7 +1731,47 @@ function textBufferReducerLogic(
} }
case 'delete': { 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); const lineContent = currentLine(cursorRow);
if (cursorCol < currentLineLen(cursorRow)) { if (cursorCol < currentLineLen(cursorRow)) {
const nextState = pushUndoLocal(state); const nextState = pushUndoLocal(state);
@@ -1734,6 +1921,7 @@ function textBufferReducerLogic(
lines: [...state.lines], lines: [...state.lines],
cursorRow: state.cursorRow, cursorRow: state.cursorRow,
cursorCol: state.cursorCol, cursorCol: state.cursorCol,
pastedContent: { ...state.pastedContent },
}; };
return { return {
...state, ...state,
@@ -1751,6 +1939,7 @@ function textBufferReducerLogic(
lines: [...state.lines], lines: [...state.lines],
cursorRow: state.cursorRow, cursorRow: state.cursorRow,
cursorCol: state.cursorCol, cursorCol: state.cursorCol,
pastedContent: { ...state.pastedContent },
}; };
return { return {
...state, ...state,
@@ -1926,6 +2115,7 @@ export function useTextBuffer({
viewportWidth: viewport.width, viewportWidth: viewport.width,
viewportHeight: viewport.height, viewportHeight: viewport.height,
visualLayout, visualLayout,
pastedContent: {},
}; };
}, [initialText, initialCursorOffset, viewport.width, viewport.height]); }, [initialText, initialCursorOffset, viewport.width, viewport.height]);
@@ -1942,6 +2132,7 @@ export function useTextBuffer({
selectionAnchor, selectionAnchor,
visualLayout, visualLayout,
transformationsByLine, transformationsByLine,
pastedContent,
} = state; } = state;
const text = useMemo(() => lines.join('\n'), [lines]); const text = useMemo(() => lines.join('\n'), [lines]);
@@ -1995,20 +2186,64 @@ export function useTextBuffer({
} }
}, [visualCursor, visualScrollRow, viewport, visualLines.length]); }, [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( const insert = useCallback(
(ch: string, { paste = false }: { paste?: boolean } = {}): void => { (ch: string, { paste = false }: { paste?: boolean } = {}): void => {
if (!singleLine && /[\n\r]/.test(ch)) { if (typeof ch !== 'string') {
dispatch({ type: 'insert', payload: ch });
return; 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; const minLengthToInferAsDragDrop = 3;
if ( if (
ch.length >= minLengthToInferAsDragDrop && text.length >= minLengthToInferAsDragDrop &&
!shellModeActive && !shellModeActive &&
paste paste
) { ) {
let potentialPath = ch.trim(); let potentialPath = text.trim();
const quoteMatch = potentialPath.match(/^'(.*)'$/); const quoteMatch = potentialPath.match(/^'(.*)'$/);
if (quoteMatch) { if (quoteMatch) {
potentialPath = quoteMatch[1]; potentialPath = quoteMatch[1];
@@ -2018,12 +2253,12 @@ export function useTextBuffer({
const processed = parsePastedPaths(potentialPath, isValidPath); const processed = parsePastedPaths(potentialPath, isValidPath);
if (processed) { if (processed) {
ch = processed; textToInsert = processed;
} }
} }
let currentText = ''; let currentText = '';
for (const char of toCodePoints(ch)) { for (const char of toCodePoints(textToInsert)) {
if (char.codePointAt(0) === 127) { if (char.codePointAt(0) === 127) {
if (currentText.length > 0) { if (currentText.length > 0) {
dispatch({ type: 'insert', payload: currentText }); dispatch({ type: 'insert', payload: currentText });
@@ -2038,7 +2273,7 @@ export function useTextBuffer({
dispatch({ type: 'insert', payload: currentText }); dispatch({ type: 'insert', payload: currentText });
} }
}, },
[isValidPath, shellModeActive, singleLine], [isValidPath, shellModeActive, singleLine, addPastedContent],
); );
const newline = useCallback((): void => { const newline = useCallback((): void => {
@@ -2435,6 +2670,7 @@ export function useTextBuffer({
cursor: [cursorRow, cursorCol], cursor: [cursorRow, cursorCol],
preferredCol, preferredCol,
selectionAnchor, selectionAnchor,
pastedContent,
allVisualLines: visualLines, allVisualLines: visualLines,
viewportVisualLines: renderedVisualLines, viewportVisualLines: renderedVisualLines,
@@ -2447,6 +2683,7 @@ export function useTextBuffer({
visualLayout, visualLayout,
setText, setText,
insert, insert,
addPastedContent,
newline, newline,
backspace, backspace,
del, del,
@@ -2506,6 +2743,7 @@ export function useTextBuffer({
cursorCol, cursorCol,
preferredCol, preferredCol,
selectionAnchor, selectionAnchor,
pastedContent,
visualLines, visualLines,
renderedVisualLines, renderedVisualLines,
visualCursor, visualCursor,
@@ -2517,6 +2755,7 @@ export function useTextBuffer({
visualLayout, visualLayout,
setText, setText,
insert, insert,
addPastedContent,
newline, newline,
backspace, backspace,
del, del,
@@ -2584,6 +2823,7 @@ export interface TextBuffer {
*/ */
preferredCol: number | null; // Preferred visual column preferredCol: number | null; // Preferred visual column
selectionAnchor: [number, number] | null; // Logical selection anchor selectionAnchor: [number, number] | null; // Logical selection anchor
pastedContent: Record<string, string>;
// Visual state (handles wrapping) // Visual state (handles wrapping)
allVisualLines: string[]; // All visual lines for the current text and viewport width. 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 a single character or string without newlines.
*/ */
insert: (ch: string, opts?: { paste?: boolean }) => void; insert: (ch: string, opts?: { paste?: boolean }) => void;
addPastedContent: (text: string, lineCount: number) => string;
newline: () => void; newline: () => void;
backspace: () => void; backspace: () => void;
del: () => void; del: () => void;
@@ -34,6 +34,7 @@ const createTestState = (
viewportHeight: 24, viewportHeight: 24,
transformationsByLine: [[]], transformationsByLine: [[]],
visualLayout: defaultVisualLayout, visualLayout: defaultVisualLayout,
pastedContent: {},
}); });
describe('vim-buffer-actions', () => { describe('vim-buffer-actions', () => {
@@ -904,7 +905,9 @@ describe('vim-buffer-actions', () => {
it('should preserve undo stack in operations', () => { it('should preserve undo stack in operations', () => {
const state = createTestState(['hello'], 0, 0); 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 = { const action = {
type: 'vim_delete_char' as const, type: 'vim_delete_char' as const,
+1
View File
@@ -67,6 +67,7 @@ const createMockTextBufferState = (
transformedToLogicalMaps: lines.map(() => []), transformedToLogicalMaps: lines.map(() => []),
visualToTransformedMap: [], visualToTransformedMap: [],
}, },
pastedContent: {},
...partial, ...partial,
}; };
}; };
+16 -5
View File
@@ -4,21 +4,28 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import {
type Transformation,
PASTED_TEXT_PLACEHOLDER_REGEX,
} from '../components/shared/text-buffer.js';
import { LRUCache } from 'mnemonist'; import { LRUCache } from 'mnemonist';
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'; import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js';
export type HighlightToken = { export type HighlightToken = {
text: string; 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` // 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, // which contain colons. It matches any character except delimiters: comma, whitespace,
// semicolon, common punctuation, and brackets. // 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[]>( const highlightCache = new LRUCache<string, readonly HighlightToken[]>(
LRU_BUFFER_PERF_CACHE_LIMIT, LRU_BUFFER_PERF_CACHE_LIMIT,
@@ -66,7 +73,11 @@ export function parseInputForHighlighting(
tokens.push({ text: text.slice(last, matchIndex), type: 'default' }); 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) { if (type === 'command' && index !== 0) {
tokens.push({ text: fullMatch, type: 'default' }); tokens.push({ text: fullMatch, type: 'default' });
} else { } else {