fix(cli): clean up stale pasted placeholder metadata after word/line deletions (#20375)

Co-authored-by: ruomeng <ruomeng@google.com>
This commit is contained in:
Jomak-x
2026-03-17 16:16:26 -04:00
committed by GitHub
parent 69e2d8c7ae
commit 1f3f7247b1
2 changed files with 230 additions and 0 deletions

View File

@@ -579,6 +579,47 @@ describe('textBufferReducer', () => {
});
});
describe('kill_line_left action', () => {
it('should clean up pastedContent when deleting a placeholder line-left', () => {
const placeholder = '[Pasted Text: 6 lines]';
const stateWithPlaceholder = createStateWithTransformations({
lines: [placeholder],
cursorRow: 0,
cursorCol: cpLen(placeholder),
pastedContent: {
[placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6',
},
});
const state = textBufferReducer(stateWithPlaceholder, {
type: 'kill_line_left',
});
expect(state.lines).toEqual(['']);
expect(state.cursorCol).toBe(0);
expect(Object.keys(state.pastedContent)).toHaveLength(0);
});
});
describe('kill_line_right action', () => {
it('should reset preferredCol when deleting to end of line', () => {
const stateWithText: TextBufferState = {
...initialState,
lines: ['hello world'],
cursorRow: 0,
cursorCol: 5,
preferredCol: 9,
};
const state = textBufferReducer(stateWithText, {
type: 'kill_line_right',
});
expect(state.lines).toEqual(['hello']);
expect(state.preferredCol).toBe(null);
});
});
describe('toggle_paste_expansion action', () => {
const placeholder = '[Pasted Text: 6 lines]';
const content = 'line1\nline2\nline3\nline4\nline5\nline6';
@@ -937,6 +978,107 @@ describe('useTextBuffer', () => {
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
});
it('deleteWordLeft: should clean up pastedContent and avoid #2 suffix on repaste', () => {
const { result } = renderHook(() => useTextBuffer({ viewport }));
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: true }));
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
act(() => {
for (let i = 0; i < 12; i++) {
result.current.deleteWordLeft();
}
});
expect(getBufferState(result).text).toBe('');
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
act(() => result.current.insert(largeText, { paste: true }));
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
});
it('deleteWordRight: should clean up pastedContent and avoid #2 suffix on repaste', () => {
const { result } = renderHook(() => useTextBuffer({ viewport }));
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: true }));
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
act(() => result.current.move('home'));
act(() => {
for (let i = 0; i < 12; i++) {
result.current.deleteWordRight();
}
});
expect(getBufferState(result).text).not.toContain(
'[Pasted Text: 6 lines]',
);
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
act(() => result.current.insert(largeText, { paste: true }));
expect(getBufferState(result).text).toContain('[Pasted Text: 6 lines]');
expect(getBufferState(result).text).not.toContain('#2');
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
});
it('killLineLeft: should clean up pastedContent and avoid #2 suffix on repaste', () => {
const { result } = renderHook(() => useTextBuffer({ viewport }));
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: true }));
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
act(() => result.current.killLineLeft());
expect(getBufferState(result).text).toBe('');
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
act(() => result.current.insert(largeText, { paste: true }));
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
});
it('killLineRight: should clean up pastedContent and avoid #2 suffix on repaste', () => {
const { result } = renderHook(() => useTextBuffer({ viewport }));
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: true }));
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
act(() => {
for (let i = 0; i < 40; i++) {
result.current.move('left');
}
});
act(() => result.current.killLineRight());
expect(getBufferState(result).text).toBe('');
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
act(() => result.current.insert(largeText, { paste: true }));
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
largeText,
);
});
it('newline: should create a new line and move cursor', () => {
const { result } = renderHook(() =>
useTextBuffer({

View File

@@ -1609,6 +1609,47 @@ function generatePastedTextId(
return id;
}
function collectPlaceholderIdsFromLines(lines: string[]): Set<string> {
const ids = new Set<string>();
const pasteRegex = new RegExp(PASTED_TEXT_PLACEHOLDER_REGEX.source, 'g');
for (const line of lines) {
if (!line) continue;
for (const match of line.matchAll(pasteRegex)) {
const placeholderId = match[0];
if (placeholderId) {
ids.add(placeholderId);
}
}
}
return ids;
}
function pruneOrphanedPastedContent(
pastedContent: Record<string, string>,
expandedPasteId: string | null,
beforeChangedLines: string[],
allLines: string[],
): Record<string, string> {
if (Object.keys(pastedContent).length === 0) return pastedContent;
const beforeIds = collectPlaceholderIdsFromLines(beforeChangedLines);
if (beforeIds.size === 0) return pastedContent;
const afterIds = collectPlaceholderIdsFromLines(allLines);
const removedIds = [...beforeIds].filter(
(id) => !afterIds.has(id) && id !== expandedPasteId,
);
if (removedIds.length === 0) return pastedContent;
const pruned = { ...pastedContent };
for (const id of removedIds) {
if (pruned[id]) {
delete pruned[id];
}
}
return pruned;
}
export type TextBufferAction =
| { type: 'insert'; payload: string; isPaste?: boolean }
| {
@@ -2260,9 +2301,11 @@ function textBufferReducerLogic(
const newLines = [...nextState.lines];
let newCursorRow = cursorRow;
let newCursorCol = cursorCol;
let beforeChangedLines: string[] = [];
if (newCursorCol > 0) {
const lineContent = currentLine(newCursorRow);
beforeChangedLines = [lineContent];
const prevWordStart = findPrevWordStartInLine(
lineContent,
newCursorCol,
@@ -2275,6 +2318,7 @@ function textBufferReducerLogic(
// Act as a backspace
const prevLineContent = currentLine(cursorRow - 1);
const currentLineContentVal = currentLine(cursorRow);
beforeChangedLines = [prevLineContent, currentLineContentVal];
const newCol = cpLen(prevLineContent);
newLines[cursorRow - 1] = prevLineContent + currentLineContentVal;
newLines.splice(cursorRow, 1);
@@ -2282,12 +2326,20 @@ function textBufferReducerLogic(
newCursorCol = newCol;
}
const newPastedContent = pruneOrphanedPastedContent(
nextState.pastedContent,
nextState.expandedPaste?.id ?? null,
beforeChangedLines,
newLines,
);
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
pastedContent: newPastedContent,
};
}
@@ -2304,23 +2356,34 @@ function textBufferReducerLogic(
const nextState = currentState;
const newLines = [...nextState.lines];
let beforeChangedLines: string[] = [];
if (cursorCol >= lineLen) {
// Act as a delete, joining with the next line
const nextLineContent = currentLine(cursorRow + 1);
beforeChangedLines = [lineContent, nextLineContent];
newLines[cursorRow] = lineContent + nextLineContent;
newLines.splice(cursorRow + 1, 1);
} else {
beforeChangedLines = [lineContent];
const nextWordStart = findNextWordStartInLine(lineContent, cursorCol);
const end = nextWordStart === null ? lineLen : nextWordStart;
newLines[cursorRow] =
cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
}
const newPastedContent = pruneOrphanedPastedContent(
nextState.pastedContent,
nextState.expandedPaste?.id ?? null,
beforeChangedLines,
newLines,
);
return {
...nextState,
lines: newLines,
preferredCol: null,
pastedContent: newPastedContent,
};
}
@@ -2332,22 +2395,39 @@ function textBufferReducerLogic(
if (cursorCol < currentLineLen(cursorRow)) {
const nextState = currentState;
const newLines = [...nextState.lines];
const beforeChangedLines = [lineContent];
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
const newPastedContent = pruneOrphanedPastedContent(
nextState.pastedContent,
nextState.expandedPaste?.id ?? null,
beforeChangedLines,
newLines,
);
return {
...nextState,
lines: newLines,
preferredCol: null,
pastedContent: newPastedContent,
};
} else if (cursorRow < lines.length - 1) {
// Act as a delete
const nextState = currentState;
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
const beforeChangedLines = [lineContent, nextLineContent];
newLines[cursorRow] = lineContent + nextLineContent;
newLines.splice(cursorRow + 1, 1);
const newPastedContent = pruneOrphanedPastedContent(
nextState.pastedContent,
nextState.expandedPaste?.id ?? null,
beforeChangedLines,
newLines,
);
return {
...nextState,
lines: newLines,
preferredCol: null,
pastedContent: newPastedContent,
};
}
return currentState;
@@ -2361,12 +2441,20 @@ function textBufferReducerLogic(
const nextState = currentState;
const lineContent = currentLine(cursorRow);
const newLines = [...nextState.lines];
const beforeChangedLines = [lineContent];
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
const newPastedContent = pruneOrphanedPastedContent(
nextState.pastedContent,
nextState.expandedPaste?.id ?? null,
beforeChangedLines,
newLines,
);
return {
...nextState,
lines: newLines,
cursorCol: 0,
preferredCol: null,
pastedContent: newPastedContent,
};
}
return currentState;