mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 08:03:39 +00:00
Merge pull request #945 from Perlence/fix/editor-multiline-insert
fix(tui): handle multiline text in insertTextAtCursor()
This commit is contained in:
commit
c94e8a122e
2 changed files with 95 additions and 54 deletions
|
|
@ -282,9 +282,6 @@ export class Editor implements Component, Focusable {
|
|||
|
||||
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
||||
private setTextInternal(text: string): void {
|
||||
// Reset kill ring state - external text changes break accumulation/yank chains
|
||||
this.lastAction = null;
|
||||
|
||||
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
this.state.lines = lines.length === 0 ? [""] : lines;
|
||||
this.state.cursorLine = this.state.lines.length - 1;
|
||||
|
|
@ -805,13 +802,13 @@ export class Editor implements Component, Focusable {
|
|||
}
|
||||
|
||||
setText(text: string): void {
|
||||
this.lastAction = null;
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
// Push undo snapshot if content differs (makes programmatic changes undoable)
|
||||
if (this.getText() !== text) {
|
||||
this.pushUndoSnapshot();
|
||||
}
|
||||
this.setTextInternal(text);
|
||||
this.lastAction = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -823,8 +820,55 @@ export class Editor implements Component, Focusable {
|
|||
if (!text) return;
|
||||
this.pushUndoSnapshot();
|
||||
this.lastAction = null;
|
||||
for (const char of text) {
|
||||
this.insertCharacter(char, true);
|
||||
this.historyIndex = -1;
|
||||
this.insertTextAtCursorInternal(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal text insertion at cursor. Handles single and multi-line text.
|
||||
* Does not push undo snapshots or trigger autocomplete - caller is responsible.
|
||||
* Normalizes line endings and calls onChange once at the end.
|
||||
*/
|
||||
private insertTextAtCursorInternal(text: string): void {
|
||||
if (!text) return;
|
||||
|
||||
// Normalize line endings
|
||||
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const insertedLines = normalized.split("\n");
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const afterCursor = currentLine.slice(this.state.cursorCol);
|
||||
|
||||
if (insertedLines.length === 1) {
|
||||
// Single line - insert at cursor position
|
||||
this.state.lines[this.state.cursorLine] = beforeCursor + normalized + afterCursor;
|
||||
this.state.cursorCol += normalized.length;
|
||||
} else {
|
||||
// Multi-line insertion
|
||||
this.state.lines = [
|
||||
// All lines before current line
|
||||
...this.state.lines.slice(0, this.state.cursorLine),
|
||||
|
||||
// The first inserted line merged with text before cursor
|
||||
beforeCursor + insertedLines[0],
|
||||
|
||||
// All middle inserted lines
|
||||
...insertedLines.slice(1, -1),
|
||||
|
||||
// The last inserted line with text after cursor
|
||||
insertedLines[insertedLines.length - 1] + afterCursor,
|
||||
|
||||
// All lines after current line
|
||||
...this.state.lines.slice(this.state.cursorLine + 1),
|
||||
];
|
||||
|
||||
this.state.cursorLine += insertedLines.length - 1;
|
||||
this.state.cursorCol = (insertedLines[insertedLines.length - 1] || "").length;
|
||||
}
|
||||
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -836,7 +880,7 @@ export class Editor implements Component, Focusable {
|
|||
// - Consecutive word chars coalesce into one undo unit
|
||||
// - Space captures state before itself (so undo removes space+following word together)
|
||||
// - Each space is separately undoable
|
||||
// Skip coalescing when called from atomic operations (paste, insertTextAtCursor)
|
||||
// Skip coalescing when called from atomic operations (e.g., handlePaste)
|
||||
if (!skipUndoCoalescing) {
|
||||
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
|
||||
this.pushUndoSnapshot();
|
||||
|
|
@ -918,7 +962,7 @@ export class Editor implements Component, Focusable {
|
|||
}
|
||||
}
|
||||
|
||||
// Split into lines
|
||||
// Split into lines to check for large paste
|
||||
const pastedLines = filteredText.split("\n");
|
||||
|
||||
// Check if this is a large paste (> 10 lines or > 1000 characters)
|
||||
|
|
@ -934,61 +978,20 @@ export class Editor implements Component, Focusable {
|
|||
pastedLines.length > 10
|
||||
? `[paste #${pasteId} +${pastedLines.length} lines]`
|
||||
: `[paste #${pasteId} ${totalChars} chars]`;
|
||||
for (const char of marker) {
|
||||
this.insertCharacter(char, true);
|
||||
}
|
||||
this.insertTextAtCursorInternal(marker);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pastedLines.length === 1) {
|
||||
// Single line - just insert each character
|
||||
const text = pastedLines[0] || "";
|
||||
for (const char of text) {
|
||||
// Single line - insert character by character to trigger autocomplete
|
||||
for (const char of filteredText) {
|
||||
this.insertCharacter(char, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-line paste - be very careful with array manipulation
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const afterCursor = currentLine.slice(this.state.cursorCol);
|
||||
|
||||
// Build the new lines array step by step
|
||||
const newLines: string[] = [];
|
||||
|
||||
// Add all lines before current line
|
||||
for (let i = 0; i < this.state.cursorLine; i++) {
|
||||
newLines.push(this.state.lines[i] || "");
|
||||
}
|
||||
|
||||
// Add the first pasted line merged with before cursor text
|
||||
newLines.push(beforeCursor + (pastedLines[0] || ""));
|
||||
|
||||
// Add all middle pasted lines
|
||||
for (let i = 1; i < pastedLines.length - 1; i++) {
|
||||
newLines.push(pastedLines[i] || "");
|
||||
}
|
||||
|
||||
// Add the last pasted line with after cursor text
|
||||
newLines.push((pastedLines[pastedLines.length - 1] || "") + afterCursor);
|
||||
|
||||
// Add all lines after current line
|
||||
for (let i = this.state.cursorLine + 1; i < this.state.lines.length; i++) {
|
||||
newLines.push(this.state.lines[i] || "");
|
||||
}
|
||||
|
||||
// Replace the entire lines array
|
||||
this.state.lines = newLines;
|
||||
|
||||
// Update cursor position to end of pasted content
|
||||
this.state.cursorLine += pastedLines.length - 1;
|
||||
this.state.cursorCol = (pastedLines[pastedLines.length - 1] || "").length;
|
||||
|
||||
// Notify of change
|
||||
if (this.onChange) {
|
||||
this.onChange(this.getText());
|
||||
}
|
||||
// Multi-line paste - use direct state manipulation
|
||||
this.insertTextAtCursorInternal(filteredText);
|
||||
}
|
||||
|
||||
private addNewLine(): void {
|
||||
|
|
|
|||
|
|
@ -1466,6 +1466,44 @@ describe("Editor component", () => {
|
|||
assert.strictEqual(editor.getText(), "hello| world");
|
||||
});
|
||||
|
||||
it("insertTextAtCursor handles multiline text", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("hello world");
|
||||
editor.handleInput("\x01"); // Ctrl+A - go to start
|
||||
for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); // Move right 5 (after "hello", before space)
|
||||
|
||||
// Insert multiline text
|
||||
editor.insertTextAtCursor("line1\nline2\nline3");
|
||||
assert.strictEqual(editor.getText(), "helloline1\nline2\nline3 world");
|
||||
|
||||
// Cursor should be at end of inserted text (after "line3", before " world")
|
||||
const cursor = editor.getCursor();
|
||||
assert.strictEqual(cursor.line, 2);
|
||||
assert.strictEqual(cursor.col, 5); // "line3".length
|
||||
|
||||
// Single undo should restore entire pre-insert state
|
||||
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||
assert.strictEqual(editor.getText(), "hello world");
|
||||
});
|
||||
|
||||
it("insertTextAtCursor normalizes CRLF and CR line endings", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
editor.setText("");
|
||||
|
||||
// Insert text with CRLF
|
||||
editor.insertTextAtCursor("a\r\nb\r\nc");
|
||||
assert.strictEqual(editor.getText(), "a\nb\nc");
|
||||
|
||||
editor.handleInput("\x1b[45;5u"); // Undo
|
||||
assert.strictEqual(editor.getText(), "");
|
||||
|
||||
// Insert text with CR only
|
||||
editor.insertTextAtCursor("x\ry\rz");
|
||||
assert.strictEqual(editor.getText(), "x\ny\nz");
|
||||
});
|
||||
|
||||
it("undoes setText to empty string", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue