fix(tui): keep paste registry in sync when deleting paste markers
Undo snapshots now restore paste content and counters alongside editor text. Paste marker renumbering shifts registry entries in ascending ID order before rewriting markers, preventing literal or incorrect paste content on submit.\n\nCloses #6844
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed editor paste registry corruption when deleting paste markers: undo now restores the paste registry together with the text, and marker renumbering shifts registry entries in ascending id order, so submitted prompts no longer contain literal `[paste #N ...]` markers or the wrong paste's content ([#6844](https://github.com/earendil-works/pi/issues/6844)).
|
||||||
|
|
||||||
## [0.80.10] - 2026-07-16
|
## [0.80.10] - 2026-07-16
|
||||||
|
|
||||||
## [0.80.9] - 2026-07-16
|
## [0.80.9] - 2026-07-16
|
||||||
|
|||||||
@@ -212,6 +212,13 @@ interface EditorState {
|
|||||||
cursorCol: number;
|
cursorCol: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undo snapshot: editor text state plus the paste registry. */
|
||||||
|
interface EditorSnapshot {
|
||||||
|
state: EditorState;
|
||||||
|
pastes: Map<number, string>;
|
||||||
|
pasteCounter: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface LayoutLine {
|
interface LayoutLine {
|
||||||
text: string;
|
text: string;
|
||||||
hasCursor: boolean;
|
hasCursor: boolean;
|
||||||
@@ -318,7 +325,7 @@ export class Editor implements Component, Focusable {
|
|||||||
private snappedFromCursorCol: number | null = null;
|
private snappedFromCursorCol: number | null = null;
|
||||||
|
|
||||||
// Undo support
|
// Undo support
|
||||||
private undoStack = new UndoStack<EditorState>();
|
private undoStack = new UndoStack<EditorSnapshot>();
|
||||||
|
|
||||||
public onSubmit?: (text: string) => void;
|
public onSubmit?: (text: string) => void;
|
||||||
public onChange?: (text: string) => void;
|
public onChange?: (text: string) => void;
|
||||||
@@ -999,13 +1006,13 @@ export class Editor implements Component, Focusable {
|
|||||||
this.cancelAutocomplete();
|
this.cancelAutocomplete();
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
this.exitHistoryBrowsing();
|
this.exitHistoryBrowsing();
|
||||||
this.pastes.clear();
|
|
||||||
this.pasteCounter = 0;
|
|
||||||
const normalized = this.normalizeText(text);
|
const normalized = this.normalizeText(text);
|
||||||
// Push undo snapshot if content differs (makes programmatic changes undoable)
|
// Push undo snapshot if content differs (makes programmatic changes undoable)
|
||||||
if (this.getText() !== normalized) {
|
if (this.getText() !== normalized) {
|
||||||
this.pushUndoSnapshot();
|
this.pushUndoSnapshot();
|
||||||
}
|
}
|
||||||
|
this.pastes.clear();
|
||||||
|
this.pasteCounter = 0;
|
||||||
this.setTextInternal(normalized);
|
this.setTextInternal(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,17 +1291,21 @@ export class Editor implements Component, Focusable {
|
|||||||
this.pastes.delete(targetId);
|
this.pastes.delete(targetId);
|
||||||
this.pasteCounter--;
|
this.pasteCounter--;
|
||||||
|
|
||||||
// We got to update id of markers which are greater than the removed one
|
// Shift registry entries down in ascending id order, independent
|
||||||
|
// of marker order in the text ([paste #3] becomes [paste #2] when
|
||||||
|
// [paste #1] is removed).
|
||||||
|
const higherIds = [...this.pastes.keys()].filter((id) => id > targetId).sort((a, b) => a - b);
|
||||||
|
for (const id of higherIds) {
|
||||||
|
this.pastes.set(id - 1, this.pastes.get(id)!);
|
||||||
|
this.pastes.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renumber markers with ids greater than the removed one.
|
||||||
this.state.lines = this.state.lines.map((line) =>
|
this.state.lines = this.state.lines.map((line) =>
|
||||||
line.replace(PASTE_MARKER_REGEX, (fullMatch, idGroup, suffixGroup) => {
|
line.replace(PASTE_MARKER_REGEX, (fullMatch, idGroup, suffixGroup) => {
|
||||||
const x = Number(idGroup);
|
const x = Number(idGroup);
|
||||||
if (x <= targetId) return fullMatch;
|
if (x <= targetId) return fullMatch;
|
||||||
|
return `[paste #${x - 1}${suffixGroup}]`;
|
||||||
// [paste #3] become [paste #2] if we remove [paste #1]
|
|
||||||
const newText = `[paste #${x - 1}${suffixGroup}]`;
|
|
||||||
this.pastes.set(x - 1, this.pastes.get(x) ?? newText);
|
|
||||||
this.pastes.delete(x);
|
|
||||||
return newText;
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1994,14 +2005,16 @@ export class Editor implements Component, Focusable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private pushUndoSnapshot(): void {
|
private pushUndoSnapshot(): void {
|
||||||
this.undoStack.push(this.state);
|
this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter });
|
||||||
}
|
}
|
||||||
|
|
||||||
private undo(): void {
|
private undo(): void {
|
||||||
this.exitHistoryBrowsing();
|
this.exitHistoryBrowsing();
|
||||||
const snapshot = this.undoStack.pop();
|
const snapshot = this.undoStack.pop();
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
Object.assign(this.state, snapshot);
|
Object.assign(this.state, snapshot.state);
|
||||||
|
this.pastes = snapshot.pastes;
|
||||||
|
this.pasteCounter = snapshot.pasteCounter;
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
this.preferredVisualCol = null;
|
this.preferredVisualCol = null;
|
||||||
if (this.onChange) {
|
if (this.onChange) {
|
||||||
|
|||||||
@@ -3553,6 +3553,11 @@ describe("Editor component", () => {
|
|||||||
return editor.getText();
|
return editor.getText();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Helper: 12-line paste content with a distinguishing tag */
|
||||||
|
function bigPaste(tag: string): string {
|
||||||
|
return Array.from({ length: 12 }, (_, i) => `${tag}${i}`).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
it("creates a paste marker for large pastes", () => {
|
it("creates a paste marker for large pastes", () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
const text = pasteWithMarker(editor);
|
const text = pasteWithMarker(editor);
|
||||||
@@ -3690,6 +3695,76 @@ describe("Editor component", () => {
|
|||||||
assert.strictEqual(editor.getText(), textBefore);
|
assert.strictEqual(editor.getText(), textBefore);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("undo after paste marker deletion restores the paste registry", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
let submitted = "";
|
||||||
|
editor.onSubmit = (t) => {
|
||||||
|
submitted = t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const paste = bigPaste("alpha");
|
||||||
|
editor.handleInput(`\x1b[200~${paste}\x1b[201~`);
|
||||||
|
editor.handleInput("\x7f"); // delete the marker
|
||||||
|
editor.handleInput("\x1b[45;5u"); // undo: restores marker text and registry
|
||||||
|
editor.handleInput("\r");
|
||||||
|
assert.strictEqual(submitted, paste);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undo after deleting the first of two paste markers restores both registry entries", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
let submitted = "";
|
||||||
|
editor.onSubmit = (t) => {
|
||||||
|
submitted = t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pasteA = bigPaste("alpha");
|
||||||
|
const pasteB = bigPaste("beta");
|
||||||
|
editor.handleInput(`\x1b[200~${pasteA}\x1b[201~`); // #1 = A
|
||||||
|
editor.handleInput(`\x1b[200~${pasteB}\x1b[201~`); // #2 = B, cursor at end
|
||||||
|
editor.handleInput("\x01"); // Ctrl+A
|
||||||
|
editor.handleInput("\x1b[C"); // right over marker #1
|
||||||
|
editor.handleInput("\x7f"); // delete marker #1, renumbers #2 -> #1
|
||||||
|
editor.handleInput("\x1b[45;5u"); // undo
|
||||||
|
editor.handleInput("\r");
|
||||||
|
assert.strictEqual(submitted, pasteA + pasteB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renumbers the paste registry in ascending id order when markers are out of order in text", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
let submitted = "";
|
||||||
|
editor.onSubmit = (t) => {
|
||||||
|
submitted = t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pasteA = bigPaste("alpha");
|
||||||
|
const pasteB = bigPaste("beta");
|
||||||
|
const pasteC = bigPaste("gamma");
|
||||||
|
editor.handleInput(`\x1b[200~${pasteA}\x1b[201~`); // #1 = A
|
||||||
|
editor.handleInput("\x01"); // Ctrl+A
|
||||||
|
editor.handleInput(`\x1b[200~${pasteB}\x1b[201~`); // #2 = B, text: [#2][#1]
|
||||||
|
editor.handleInput("\x01"); // Ctrl+A
|
||||||
|
editor.handleInput(`\x1b[200~${pasteC}\x1b[201~`); // #3 = C, text: [#3][#2][#1]
|
||||||
|
editor.handleInput("\x05"); // Ctrl+E
|
||||||
|
editor.handleInput("\x7f"); // delete marker #1, renumber #3 -> #2 and #2 -> #1
|
||||||
|
editor.handleInput("\r");
|
||||||
|
assert.strictEqual(submitted, pasteC + pasteB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undo after setText restores paste markers and registry", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
let submitted = "";
|
||||||
|
editor.onSubmit = (t) => {
|
||||||
|
submitted = t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const paste = bigPaste("alpha");
|
||||||
|
editor.handleInput(`\x1b[200~${paste}\x1b[201~`);
|
||||||
|
editor.setText("replacement");
|
||||||
|
editor.handleInput("\x1b[45;5u"); // undo
|
||||||
|
editor.handleInput("\r");
|
||||||
|
assert.strictEqual(submitted, paste);
|
||||||
|
});
|
||||||
|
|
||||||
it("handles multiple paste markers in same line", () => {
|
it("handles multiple paste markers in same line", () => {
|
||||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
pasteWithMarker(editor);
|
pasteWithMarker(editor);
|
||||||
|
|||||||
Reference in New Issue
Block a user