From 1f3f7247b1569a6c035bd19fbf480c1f276f8fc0 Mon Sep 17 00:00:00 2001 From: Jomak-x Date: Tue, 17 Mar 2026 16:16:26 -0400 Subject: [PATCH] fix(cli): clean up stale pasted placeholder metadata after word/line deletions (#20375) Co-authored-by: ruomeng --- .../ui/components/shared/text-buffer.test.ts | 142 ++++++++++++++++++ .../src/ui/components/shared/text-buffer.ts | 88 +++++++++++ 2 files changed, 230 insertions(+) diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index ff4f3495d7..cd2648b81d 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -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({ diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index ad04ff91fe..72d842ec98 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1609,6 +1609,47 @@ function generatePastedTextId( return id; } +function collectPlaceholderIdsFromLines(lines: string[]): Set { + const ids = new Set(); + 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, + expandedPasteId: string | null, + beforeChangedLines: string[], + allLines: string[], +): Record { + 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;