feat: text-buffer: input sanitization and delete character handling. (#1031)
This commit is contained in:
parent
742caa5dd8
commit
5d4f4f421c
|
@ -585,6 +585,68 @@ describe('useTextBuffer', () => {
|
|||
expect(getBufferState(result).text).toBe('');
|
||||
});
|
||||
|
||||
it('should handle multiple delete characters in one input', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
initialText: 'abcde',
|
||||
viewport,
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.move('end')); // cursor at the end
|
||||
expect(getBufferState(result).cursor).toEqual([0, 5]);
|
||||
|
||||
act(() => {
|
||||
result.current.applyOperations([
|
||||
{ type: 'backspace' },
|
||||
{ type: 'backspace' },
|
||||
{ type: 'backspace' },
|
||||
]);
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('ab');
|
||||
expect(getBufferState(result).cursor).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it('should handle inserts that contain delete characters ', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
initialText: 'abcde',
|
||||
viewport,
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.move('end')); // cursor at the end
|
||||
expect(getBufferState(result).cursor).toEqual([0, 5]);
|
||||
|
||||
act(() => {
|
||||
result.current.applyOperations([
|
||||
{ type: 'insert', payload: '\x7f\x7f\x7f' },
|
||||
]);
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('ab');
|
||||
expect(getBufferState(result).cursor).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it('should handle inserts with a mix of regular and delete characters ', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
initialText: 'abcde',
|
||||
viewport,
|
||||
isValidPath: () => false,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.move('end')); // cursor at the end
|
||||
expect(getBufferState(result).cursor).toEqual([0, 5]);
|
||||
|
||||
act(() => {
|
||||
result.current.applyOperations([
|
||||
{ type: 'insert', payload: '\x7fI\x7f\x7fNEW' },
|
||||
]);
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('abcNEW');
|
||||
expect(getBufferState(result).cursor).toEqual([0, 6]);
|
||||
});
|
||||
|
||||
it('should handle arrow keys for movement', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
|
@ -632,9 +694,13 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
|||
);
|
||||
|
||||
// Simulate pasting the long text multiple times
|
||||
act(() => result.current.insertStr(longText));
|
||||
act(() => result.current.insertStr(longText));
|
||||
act(() => result.current.insertStr(longText));
|
||||
act(() => {
|
||||
result.current.applyOperations([
|
||||
{ type: 'insert', payload: longText },
|
||||
{ type: 'insert', payload: longText },
|
||||
{ type: 'insert', payload: longText },
|
||||
]);
|
||||
});
|
||||
|
||||
const state = getBufferState(result);
|
||||
// Check that the text is the result of three concatenations.
|
||||
|
@ -792,6 +858,53 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
|||
expect(state.cursor).toEqual([0, 3]); // After 'X'
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Sanitization', () => {
|
||||
it('should strip ANSI escape codes from input', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const textWithAnsi = '\x1B[31mHello\x1B[0m';
|
||||
act(() => result.current.handleInput(textWithAnsi, {}));
|
||||
expect(getBufferState(result).text).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should strip control characters from input', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const textWithControlChars = 'H\x07e\x08l\x0Bl\x0Co'; // BELL, BACKSPACE, VT, FF
|
||||
act(() => result.current.handleInput(textWithControlChars, {}));
|
||||
expect(getBufferState(result).text).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should strip mixed ANSI and control characters from input', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const textWithMixed = '\u001B[4mH\u001B[0mello';
|
||||
act(() => result.current.handleInput(textWithMixed, {}));
|
||||
expect(getBufferState(result).text).toBe('Hello');
|
||||
});
|
||||
|
||||
it('should not strip standard characters or newlines', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const validText = 'Hello World\nThis is a test.';
|
||||
act(() => result.current.handleInput(validText, {}));
|
||||
expect(getBufferState(result).text).toBe(validText);
|
||||
});
|
||||
|
||||
it('should sanitize pasted text via handleInput', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||
);
|
||||
const pastedText = '\u001B[4mPasted\u001B[4m Text';
|
||||
act(() => result.current.handleInput(pastedText, {}));
|
||||
expect(getBufferState(result).text).toBe('Pasted Text');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('offsetToLogicalPos', () => {
|
||||
|
|
|
@ -23,6 +23,13 @@ export type Direction =
|
|||
| 'home'
|
||||
| 'end';
|
||||
|
||||
// TODO(jacob314): refactor so all edit operations to be part of this list.
|
||||
// This makes it robust for clients to apply multiple edit operations without
|
||||
// having to carefully reason about how React manages state.
|
||||
type UpdateOperation =
|
||||
| { type: 'insert'; payload: string }
|
||||
| { type: 'backspace' };
|
||||
|
||||
// Simple helper for word‑wise ops.
|
||||
function isWordChar(ch: string | undefined): boolean {
|
||||
if (ch === undefined) {
|
||||
|
@ -31,6 +38,28 @@ function isWordChar(ch: string | undefined): boolean {
|
|||
return !/[\s,.;!?]/.test(ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip characters that can break terminal rendering.
|
||||
*
|
||||
* Strip ANSI escape codes and control characters except for line breaks.
|
||||
* Control characters such as delete break terminal UI rendering.
|
||||
*/
|
||||
function stripUnsafeCharacters(str: string): string {
|
||||
const stripped = stripAnsi(str);
|
||||
return toCodePoints(stripAnsi(stripped))
|
||||
.filter((char) => {
|
||||
if (char.length > 1) return false;
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) {
|
||||
return false;
|
||||
}
|
||||
const isUnsafe =
|
||||
code === 127 || (code <= 31 && code !== 13 && code !== 10);
|
||||
return !isUnsafe;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
height: number;
|
||||
width: number;
|
||||
|
@ -524,14 +553,15 @@ export function useTextBuffer({
|
|||
if (str === '') return false;
|
||||
|
||||
pushUndo();
|
||||
const normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
let normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
normalised = stripUnsafeCharacters(normalised);
|
||||
|
||||
const parts = normalised.split('\n');
|
||||
|
||||
const newLines = [...lines];
|
||||
const lineContent = currentLine(cursorRow);
|
||||
const before = cpSlice(lineContent, 0, cursorCol);
|
||||
const after = cpSlice(lineContent, cursorCol);
|
||||
|
||||
newLines[cursorRow] = before + parts[0];
|
||||
|
||||
if (parts.length > 1) {
|
||||
|
@ -556,6 +586,101 @@ export function useTextBuffer({
|
|||
[pushUndo, cursorRow, cursorCol, lines, currentLine, setPreferredCol],
|
||||
);
|
||||
|
||||
const applyOperations = useCallback(
|
||||
(ops: UpdateOperation[]) => {
|
||||
if (ops.length === 0) return;
|
||||
|
||||
const expandedOps: UpdateOperation[] = [];
|
||||
for (const op of ops) {
|
||||
if (op.type === 'insert') {
|
||||
let currentText = '';
|
||||
for (const char of toCodePoints(op.payload)) {
|
||||
if (char.codePointAt(0) === 127) {
|
||||
// \x7f
|
||||
if (currentText.length > 0) {
|
||||
expandedOps.push({ type: 'insert', payload: currentText });
|
||||
currentText = '';
|
||||
}
|
||||
expandedOps.push({ type: 'backspace' });
|
||||
} else {
|
||||
currentText += char;
|
||||
}
|
||||
}
|
||||
if (currentText.length > 0) {
|
||||
expandedOps.push({ type: 'insert', payload: currentText });
|
||||
}
|
||||
} else {
|
||||
expandedOps.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
if (expandedOps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushUndo(); // Snapshot before applying batch of updates
|
||||
|
||||
const newLines = [...lines];
|
||||
let newCursorRow = cursorRow;
|
||||
let newCursorCol = cursorCol;
|
||||
|
||||
const currentLine = (r: number) => newLines[r] ?? '';
|
||||
|
||||
for (const op of expandedOps) {
|
||||
if (op.type === 'insert') {
|
||||
const str = stripUnsafeCharacters(
|
||||
op.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'),
|
||||
);
|
||||
const parts = str.split('\n');
|
||||
const lineContent = currentLine(newCursorRow);
|
||||
const before = cpSlice(lineContent, 0, newCursorCol);
|
||||
const after = cpSlice(lineContent, newCursorCol);
|
||||
newLines[newCursorRow] = before + parts[0];
|
||||
|
||||
if (parts.length > 1) {
|
||||
const remainingParts = parts.slice(1);
|
||||
const lastPartOriginal = remainingParts.pop() ?? '';
|
||||
newLines.splice(newCursorRow + 1, 0, ...remainingParts);
|
||||
newLines.splice(
|
||||
newCursorRow + parts.length - 1,
|
||||
0,
|
||||
lastPartOriginal + after,
|
||||
);
|
||||
newCursorRow = newCursorRow + parts.length - 1;
|
||||
newCursorCol = cpLen(lastPartOriginal);
|
||||
} else {
|
||||
newCursorCol = cpLen(before) + cpLen(parts[0]);
|
||||
}
|
||||
} else if (op.type === 'backspace') {
|
||||
if (newCursorCol === 0 && newCursorRow === 0) continue;
|
||||
|
||||
if (newCursorCol > 0) {
|
||||
const lineContent = currentLine(newCursorRow);
|
||||
newLines[newCursorRow] =
|
||||
cpSlice(lineContent, 0, newCursorCol - 1) +
|
||||
cpSlice(lineContent, newCursorCol);
|
||||
newCursorCol--;
|
||||
} else if (newCursorRow > 0) {
|
||||
const prevLineContent = currentLine(newCursorRow - 1);
|
||||
const currentLineContentVal = currentLine(newCursorRow);
|
||||
const newCol = cpLen(prevLineContent);
|
||||
newLines[newCursorRow - 1] =
|
||||
prevLineContent + currentLineContentVal;
|
||||
newLines.splice(newCursorRow, 1);
|
||||
newCursorRow--;
|
||||
newCursorCol = newCol;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLines(newLines);
|
||||
setCursorRow(newCursorRow);
|
||||
setCursorCol(newCursorCol);
|
||||
setPreferredCol(null);
|
||||
},
|
||||
[lines, cursorRow, cursorCol, pushUndo, setPreferredCol],
|
||||
);
|
||||
|
||||
const insert = useCallback(
|
||||
(ch: string): void => {
|
||||
if (/[\n\r]/.test(ch)) {
|
||||
|
@ -563,7 +688,8 @@ export function useTextBuffer({
|
|||
return;
|
||||
}
|
||||
dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] });
|
||||
pushUndo();
|
||||
|
||||
ch = stripUnsafeCharacters(ch);
|
||||
|
||||
// Arbitrary threshold to avoid false positives on normal key presses
|
||||
// while still detecting virtually all reasonable length file paths.
|
||||
|
@ -585,77 +711,21 @@ export function useTextBuffer({
|
|||
ch = `@${potentialPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
setLines((prevLines) => {
|
||||
const newLines = [...prevLines];
|
||||
const lineContent = currentLine(cursorRow);
|
||||
newLines[cursorRow] =
|
||||
cpSlice(lineContent, 0, cursorCol) +
|
||||
ch +
|
||||
cpSlice(lineContent, cursorCol);
|
||||
return newLines;
|
||||
});
|
||||
setCursorCol((prev) => prev + cpLen(ch)); // Use cpLen for character length
|
||||
setPreferredCol(null);
|
||||
applyOperations([{ type: 'insert', payload: ch }]);
|
||||
},
|
||||
[
|
||||
pushUndo,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
currentLine,
|
||||
insertStr,
|
||||
setPreferredCol,
|
||||
isValidPath,
|
||||
],
|
||||
[applyOperations, cursorRow, cursorCol, isValidPath, insertStr],
|
||||
);
|
||||
|
||||
const newline = useCallback((): void => {
|
||||
dbg('newline', { beforeCursor: [cursorRow, cursorCol] });
|
||||
pushUndo();
|
||||
setLines((prevLines) => {
|
||||
const newLines = [...prevLines];
|
||||
const l = currentLine(cursorRow);
|
||||
const before = cpSlice(l, 0, cursorCol);
|
||||
const after = cpSlice(l, cursorCol);
|
||||
newLines[cursorRow] = before;
|
||||
newLines.splice(cursorRow + 1, 0, after);
|
||||
return newLines;
|
||||
});
|
||||
setCursorRow((prev) => prev + 1);
|
||||
setCursorCol(0);
|
||||
setPreferredCol(null);
|
||||
}, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]);
|
||||
applyOperations([{ type: 'insert', payload: '\n' }]);
|
||||
}, [applyOperations, cursorRow, cursorCol]);
|
||||
|
||||
const backspace = useCallback((): void => {
|
||||
dbg('backspace', { beforeCursor: [cursorRow, cursorCol] });
|
||||
if (cursorCol === 0 && cursorRow === 0) return;
|
||||
|
||||
pushUndo();
|
||||
if (cursorCol > 0) {
|
||||
setLines((prevLines) => {
|
||||
const newLines = [...prevLines];
|
||||
const lineContent = currentLine(cursorRow);
|
||||
newLines[cursorRow] =
|
||||
cpSlice(lineContent, 0, cursorCol - 1) +
|
||||
cpSlice(lineContent, cursorCol);
|
||||
return newLines;
|
||||
});
|
||||
setCursorCol((prev) => prev - 1);
|
||||
} else if (cursorRow > 0) {
|
||||
const prevLineContent = currentLine(cursorRow - 1);
|
||||
const currentLineContentVal = currentLine(cursorRow);
|
||||
const newCol = cpLen(prevLineContent);
|
||||
setLines((prevLines) => {
|
||||
const newLines = [...prevLines];
|
||||
newLines[cursorRow - 1] = prevLineContent + currentLineContentVal;
|
||||
newLines.splice(cursorRow, 1);
|
||||
return newLines;
|
||||
});
|
||||
setCursorRow((prev) => prev - 1);
|
||||
setCursorCol(newCol);
|
||||
}
|
||||
setPreferredCol(null);
|
||||
}, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]);
|
||||
applyOperations([{ type: 'backspace' }]);
|
||||
}, [applyOperations, cursorRow, cursorCol]);
|
||||
|
||||
const del = useCallback((): void => {
|
||||
dbg('delete', { beforeCursor: [cursorRow, cursorCol] });
|
||||
|
@ -1224,10 +1294,7 @@ export function useTextBuffer({
|
|||
backspace();
|
||||
else if (key['delete'] || (key['ctrl'] && input === 'd')) del();
|
||||
else if (input && !key['ctrl'] && !key['meta']) {
|
||||
// Heuristic for paste: if input is longer than 1 char (potential paste)
|
||||
// strip ANSI escape codes.
|
||||
const cleanedInput = input.length > 1 ? stripAnsi(input) : input;
|
||||
insert(cleanedInput);
|
||||
insert(input);
|
||||
}
|
||||
|
||||
const textChanged = text !== beforeText;
|
||||
|
@ -1306,7 +1373,6 @@ export function useTextBuffer({
|
|||
|
||||
setText,
|
||||
insert,
|
||||
insertStr,
|
||||
newline,
|
||||
backspace,
|
||||
del,
|
||||
|
@ -1323,6 +1389,8 @@ export function useTextBuffer({
|
|||
handleInput,
|
||||
openInExternalEditor,
|
||||
|
||||
applyOperations,
|
||||
|
||||
copy: useCallback(() => {
|
||||
if (!selectionAnchor) return null;
|
||||
const [ar, ac] = selectionAnchor;
|
||||
|
@ -1386,7 +1454,6 @@ export interface TextBuffer {
|
|||
* Insert a single character or string without newlines.
|
||||
*/
|
||||
insert: (ch: string) => void;
|
||||
insertStr: (str: string) => boolean;
|
||||
newline: () => void;
|
||||
backspace: () => void;
|
||||
del: () => void;
|
||||
|
@ -1466,4 +1533,7 @@ export interface TextBuffer {
|
|||
replacementText: string,
|
||||
) => boolean;
|
||||
moveToOffset(offset: number): void;
|
||||
|
||||
// Batch updates
|
||||
applyOperations: (ops: UpdateOperation[]) => void;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue