mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 18:03:50 +00:00
Add the kill ring and undo features to the Input component (#1373)
* feat(tui): extract KillRing and UndoStack, add to Input Extract kill ring and undo logic from Editor into reusable classes: - KillRing: ring buffer with accumulation for consecutive kills - UndoStack<S>: generic stack with clone-on-push semantics Refactor Editor to use both classes. Add kill ring (kill/yank/ yank-pop), undo with coalescing, and deleteWordForward to Input. * feat(tui): extract handleBackspace() and handleForwardDelete()
This commit is contained in:
parent
da282f9e97
commit
4c2d78f6cb
5 changed files with 765 additions and 79 deletions
|
|
@ -1,7 +1,9 @@
|
|||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
|
||||
import { getEditorKeybindings } from "../keybindings.js";
|
||||
import { matchesKey } from "../keys.js";
|
||||
import { KillRing } from "../kill-ring.js";
|
||||
import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js";
|
||||
import { UndoStack } from "../undo-stack.js";
|
||||
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
|
||||
import { SelectList, type SelectListTheme } from "./select-list.js";
|
||||
|
||||
|
|
@ -192,8 +194,7 @@ export class Editor implements Component, Focusable {
|
|||
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
||||
|
||||
// Kill ring for Emacs-style kill/yank operations
|
||||
// Also tracks undo coalescing: "type-word" means we're mid-word (coalescing)
|
||||
private killRing: string[] = [];
|
||||
private killRing = new KillRing();
|
||||
private lastAction: "kill" | "yank" | "type-word" | null = null;
|
||||
|
||||
// Character jump mode
|
||||
|
|
@ -203,7 +204,7 @@ export class Editor implements Component, Focusable {
|
|||
private preferredVisualCol: number | null = null;
|
||||
|
||||
// Undo support
|
||||
private undoStack: EditorState[] = [];
|
||||
private undoStack = new UndoStack<EditorState>();
|
||||
|
||||
public onSubmit?: (text: string) => void;
|
||||
public onChange?: (text: string) => void;
|
||||
|
|
@ -1081,7 +1082,7 @@ export class Editor implements Component, Focusable {
|
|||
this.pasteCounter = 0;
|
||||
this.historyIndex = -1;
|
||||
this.scrollOffset = 0;
|
||||
this.undoStack.length = 0;
|
||||
this.undoStack.clear();
|
||||
this.lastAction = null;
|
||||
|
||||
if (this.onChange) this.onChange("");
|
||||
|
|
@ -1268,7 +1269,7 @@ export class Editor implements Component, Focusable {
|
|||
|
||||
// Calculate text to be deleted and save to kill ring (backward deletion = prepend)
|
||||
const deletedText = currentLine.slice(0, this.state.cursorCol);
|
||||
this.addToKillRing(deletedText, true);
|
||||
this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" });
|
||||
this.lastAction = "kill";
|
||||
|
||||
// Delete from start of line up to cursor
|
||||
|
|
@ -1278,7 +1279,7 @@ export class Editor implements Component, Focusable {
|
|||
this.pushUndoSnapshot();
|
||||
|
||||
// At start of line - merge with previous line, treating newline as deleted text
|
||||
this.addToKillRing("\n", true);
|
||||
this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
|
||||
this.lastAction = "kill";
|
||||
|
||||
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
||||
|
|
@ -1303,7 +1304,7 @@ export class Editor implements Component, Focusable {
|
|||
|
||||
// Calculate text to be deleted and save to kill ring (forward deletion = append)
|
||||
const deletedText = currentLine.slice(this.state.cursorCol);
|
||||
this.addToKillRing(deletedText, false);
|
||||
this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" });
|
||||
this.lastAction = "kill";
|
||||
|
||||
// Delete from cursor to end of line
|
||||
|
|
@ -1312,7 +1313,7 @@ export class Editor implements Component, Focusable {
|
|||
this.pushUndoSnapshot();
|
||||
|
||||
// At end of line - merge with next line, treating newline as deleted text
|
||||
this.addToKillRing("\n", false);
|
||||
this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
|
||||
this.lastAction = "kill";
|
||||
|
||||
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
||||
|
|
@ -1336,7 +1337,7 @@ export class Editor implements Component, Focusable {
|
|||
this.pushUndoSnapshot();
|
||||
|
||||
// Treat newline as deleted text (backward deletion = prepend)
|
||||
this.addToKillRing("\n", true);
|
||||
this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" });
|
||||
this.lastAction = "kill";
|
||||
|
||||
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
||||
|
|
@ -1356,10 +1357,8 @@ export class Editor implements Component, Focusable {
|
|||
const deleteFrom = this.state.cursorCol;
|
||||
this.setCursorCol(oldCursorCol);
|
||||
|
||||
// Restore kill state for accumulation check, then save to kill ring
|
||||
this.lastAction = wasKill ? "kill" : null;
|
||||
const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol);
|
||||
this.addToKillRing(deletedText, true);
|
||||
this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
|
||||
this.lastAction = "kill";
|
||||
|
||||
this.state.lines[this.state.cursorLine] =
|
||||
|
|
@ -1383,7 +1382,7 @@ export class Editor implements Component, Focusable {
|
|||
this.pushUndoSnapshot();
|
||||
|
||||
// Treat newline as deleted text (forward deletion = append)
|
||||
this.addToKillRing("\n", false);
|
||||
this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" });
|
||||
this.lastAction = "kill";
|
||||
|
||||
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
||||
|
|
@ -1401,10 +1400,8 @@ export class Editor implements Component, Focusable {
|
|||
const deleteTo = this.state.cursorCol;
|
||||
this.setCursorCol(oldCursorCol);
|
||||
|
||||
// Restore kill state for accumulation check, then save to kill ring
|
||||
this.lastAction = wasKill ? "kill" : null;
|
||||
const deletedText = currentLine.slice(this.state.cursorCol, deleteTo);
|
||||
this.addToKillRing(deletedText, false);
|
||||
this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
|
||||
this.lastAction = "kill";
|
||||
|
||||
this.state.lines[this.state.cursorLine] =
|
||||
|
|
@ -1644,7 +1641,7 @@ export class Editor implements Component, Focusable {
|
|||
|
||||
this.pushUndoSnapshot();
|
||||
|
||||
const text = this.killRing[this.killRing.length - 1] || "";
|
||||
const text = this.killRing.peek()!;
|
||||
this.insertYankedText(text);
|
||||
|
||||
this.lastAction = "yank";
|
||||
|
|
@ -1664,11 +1661,10 @@ export class Editor implements Component, Focusable {
|
|||
this.deleteYankedText();
|
||||
|
||||
// Rotate the ring: move end to front
|
||||
const lastEntry = this.killRing.pop()!;
|
||||
this.killRing.unshift(lastEntry);
|
||||
this.killRing.rotate();
|
||||
|
||||
// Insert the new most recent entry (now at end after rotation)
|
||||
const text = this.killRing[this.killRing.length - 1];
|
||||
const text = this.killRing.peek()!;
|
||||
this.insertYankedText(text);
|
||||
|
||||
this.lastAction = "yank";
|
||||
|
|
@ -1721,7 +1717,7 @@ export class Editor implements Component, Focusable {
|
|||
* The yanked text is derived from killRing[end] since it hasn't been rotated yet.
|
||||
*/
|
||||
private deleteYankedText(): void {
|
||||
const yankedText = this.killRing[this.killRing.length - 1] || "";
|
||||
const yankedText = this.killRing.peek();
|
||||
if (!yankedText) return;
|
||||
|
||||
const yankLines = yankedText.split("\n");
|
||||
|
|
@ -1758,46 +1754,15 @@ export class Editor implements Component, Focusable {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add text to the kill ring.
|
||||
* If lastAction is "kill", accumulates with the previous entry.
|
||||
* @param text - The text to add
|
||||
* @param prepend - If accumulating, prepend (true) or append (false) to existing entry
|
||||
*/
|
||||
private addToKillRing(text: string, prepend: boolean): void {
|
||||
if (!text) return;
|
||||
|
||||
if (this.lastAction === "kill" && this.killRing.length > 0) {
|
||||
// Accumulate with the most recent entry (at end of array)
|
||||
const lastEntry = this.killRing.pop();
|
||||
if (prepend) {
|
||||
this.killRing.push(text + lastEntry);
|
||||
} else {
|
||||
this.killRing.push(lastEntry + text);
|
||||
}
|
||||
} else {
|
||||
// Add new entry to end of ring
|
||||
this.killRing.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
private captureUndoSnapshot(): EditorState {
|
||||
return structuredClone(this.state);
|
||||
}
|
||||
|
||||
private restoreUndoSnapshot(snapshot: EditorState): void {
|
||||
Object.assign(this.state, structuredClone(snapshot));
|
||||
}
|
||||
|
||||
private pushUndoSnapshot(): void {
|
||||
this.undoStack.push(this.captureUndoSnapshot());
|
||||
this.undoStack.push(this.state);
|
||||
}
|
||||
|
||||
private undo(): void {
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
if (this.undoStack.length === 0) return;
|
||||
const snapshot = this.undoStack.pop()!;
|
||||
this.restoreUndoSnapshot(snapshot);
|
||||
const snapshot = this.undoStack.pop();
|
||||
if (!snapshot) return;
|
||||
Object.assign(this.state, snapshot);
|
||||
this.lastAction = null;
|
||||
this.preferredVisualCol = null;
|
||||
if (this.onChange) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import { getEditorKeybindings } from "../keybindings.js";
|
||||
import { KillRing } from "../kill-ring.js";
|
||||
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
|
||||
import { UndoStack } from "../undo-stack.js";
|
||||
import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js";
|
||||
|
||||
const segmenter = getSegmenter();
|
||||
|
||||
interface InputState {
|
||||
value: string;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input component - single-line text input with horizontal scrolling
|
||||
*/
|
||||
|
|
@ -20,6 +27,13 @@ export class Input implements Component, Focusable {
|
|||
private pasteBuffer: string = "";
|
||||
private isInPaste: boolean = false;
|
||||
|
||||
// Kill ring for Emacs-style kill/yank operations
|
||||
private killRing = new KillRing();
|
||||
private lastAction: "kill" | "yank" | "type-word" | null = null;
|
||||
|
||||
// Undo support
|
||||
private undoStack = new UndoStack<InputState>();
|
||||
|
||||
getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
|
@ -75,6 +89,12 @@ export class Input implements Component, Focusable {
|
|||
return;
|
||||
}
|
||||
|
||||
// Undo
|
||||
if (kb.matches(data, "undo")) {
|
||||
this.undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit
|
||||
if (kb.matches(data, "submit") || data === "\n") {
|
||||
if (this.onSubmit) this.onSubmit(this.value);
|
||||
|
|
@ -83,25 +103,12 @@ export class Input implements Component, Focusable {
|
|||
|
||||
// Deletion
|
||||
if (kb.matches(data, "deleteCharBackward")) {
|
||||
if (this.cursor > 0) {
|
||||
const beforeCursor = this.value.slice(0, this.cursor);
|
||||
const graphemes = [...segmenter.segment(beforeCursor)];
|
||||
const lastGrapheme = graphemes[graphemes.length - 1];
|
||||
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
||||
this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor);
|
||||
this.cursor -= graphemeLength;
|
||||
}
|
||||
this.handleBackspace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "deleteCharForward")) {
|
||||
if (this.cursor < this.value.length) {
|
||||
const afterCursor = this.value.slice(this.cursor);
|
||||
const graphemes = [...segmenter.segment(afterCursor)];
|
||||
const firstGrapheme = graphemes[0];
|
||||
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
||||
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength);
|
||||
}
|
||||
this.handleForwardDelete();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -110,19 +117,34 @@ export class Input implements Component, Focusable {
|
|||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "deleteWordForward")) {
|
||||
this.deleteWordForward();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "deleteToLineStart")) {
|
||||
this.value = this.value.slice(this.cursor);
|
||||
this.cursor = 0;
|
||||
this.deleteToLineStart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "deleteToLineEnd")) {
|
||||
this.value = this.value.slice(0, this.cursor);
|
||||
this.deleteToLineEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill ring actions
|
||||
if (kb.matches(data, "yank")) {
|
||||
this.yank();
|
||||
return;
|
||||
}
|
||||
if (kb.matches(data, "yankPop")) {
|
||||
this.yankPop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cursor movement
|
||||
if (kb.matches(data, "cursorLeft")) {
|
||||
this.lastAction = null;
|
||||
if (this.cursor > 0) {
|
||||
const beforeCursor = this.value.slice(0, this.cursor);
|
||||
const graphemes = [...segmenter.segment(beforeCursor)];
|
||||
|
|
@ -133,6 +155,7 @@ export class Input implements Component, Focusable {
|
|||
}
|
||||
|
||||
if (kb.matches(data, "cursorRight")) {
|
||||
this.lastAction = null;
|
||||
if (this.cursor < this.value.length) {
|
||||
const afterCursor = this.value.slice(this.cursor);
|
||||
const graphemes = [...segmenter.segment(afterCursor)];
|
||||
|
|
@ -143,11 +166,13 @@ export class Input implements Component, Focusable {
|
|||
}
|
||||
|
||||
if (kb.matches(data, "cursorLineStart")) {
|
||||
this.lastAction = null;
|
||||
this.cursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "cursorLineEnd")) {
|
||||
this.lastAction = null;
|
||||
this.cursor = this.value.length;
|
||||
return;
|
||||
}
|
||||
|
|
@ -169,30 +194,153 @@ export class Input implements Component, Focusable {
|
|||
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
|
||||
});
|
||||
if (!hasControlChars) {
|
||||
this.value = this.value.slice(0, this.cursor) + data + this.value.slice(this.cursor);
|
||||
this.cursor += data.length;
|
||||
this.insertCharacter(data);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteWordBackwards(): void {
|
||||
if (this.cursor === 0) {
|
||||
return;
|
||||
private insertCharacter(char: string): void {
|
||||
// Undo coalescing: consecutive word chars coalesce into one undo unit
|
||||
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
|
||||
this.pushUndo();
|
||||
}
|
||||
this.lastAction = "type-word";
|
||||
|
||||
this.value = this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor);
|
||||
this.cursor += char.length;
|
||||
}
|
||||
|
||||
private handleBackspace(): void {
|
||||
this.lastAction = null;
|
||||
if (this.cursor > 0) {
|
||||
this.pushUndo();
|
||||
const beforeCursor = this.value.slice(0, this.cursor);
|
||||
const graphemes = [...segmenter.segment(beforeCursor)];
|
||||
const lastGrapheme = graphemes[graphemes.length - 1];
|
||||
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
||||
this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor);
|
||||
this.cursor -= graphemeLength;
|
||||
}
|
||||
}
|
||||
|
||||
private handleForwardDelete(): void {
|
||||
this.lastAction = null;
|
||||
if (this.cursor < this.value.length) {
|
||||
this.pushUndo();
|
||||
const afterCursor = this.value.slice(this.cursor);
|
||||
const graphemes = [...segmenter.segment(afterCursor)];
|
||||
const firstGrapheme = graphemes[0];
|
||||
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
||||
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength);
|
||||
}
|
||||
}
|
||||
|
||||
private deleteToLineStart(): void {
|
||||
if (this.cursor === 0) return;
|
||||
this.pushUndo();
|
||||
const deletedText = this.value.slice(0, this.cursor);
|
||||
this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" });
|
||||
this.lastAction = "kill";
|
||||
this.value = this.value.slice(this.cursor);
|
||||
this.cursor = 0;
|
||||
}
|
||||
|
||||
private deleteToLineEnd(): void {
|
||||
if (this.cursor >= this.value.length) return;
|
||||
this.pushUndo();
|
||||
const deletedText = this.value.slice(this.cursor);
|
||||
this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" });
|
||||
this.lastAction = "kill";
|
||||
this.value = this.value.slice(0, this.cursor);
|
||||
}
|
||||
|
||||
private deleteWordBackwards(): void {
|
||||
if (this.cursor === 0) return;
|
||||
|
||||
// Save lastAction before cursor movement (moveWordBackwards resets it)
|
||||
const wasKill = this.lastAction === "kill";
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
const oldCursor = this.cursor;
|
||||
this.moveWordBackwards();
|
||||
const deleteFrom = this.cursor;
|
||||
this.cursor = oldCursor;
|
||||
|
||||
const deletedText = this.value.slice(deleteFrom, this.cursor);
|
||||
this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
|
||||
this.lastAction = "kill";
|
||||
|
||||
this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor);
|
||||
this.cursor = deleteFrom;
|
||||
}
|
||||
|
||||
private deleteWordForward(): void {
|
||||
if (this.cursor >= this.value.length) return;
|
||||
|
||||
// Save lastAction before cursor movement (moveWordForwards resets it)
|
||||
const wasKill = this.lastAction === "kill";
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
const oldCursor = this.cursor;
|
||||
this.moveWordForwards();
|
||||
const deleteTo = this.cursor;
|
||||
this.cursor = oldCursor;
|
||||
|
||||
const deletedText = this.value.slice(this.cursor, deleteTo);
|
||||
this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
|
||||
this.lastAction = "kill";
|
||||
|
||||
this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo);
|
||||
}
|
||||
|
||||
private yank(): void {
|
||||
const text = this.killRing.peek();
|
||||
if (!text) return;
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
|
||||
this.cursor += text.length;
|
||||
this.lastAction = "yank";
|
||||
}
|
||||
|
||||
private yankPop(): void {
|
||||
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
|
||||
|
||||
this.pushUndo();
|
||||
|
||||
// Delete the previously yanked text (still at end of ring before rotation)
|
||||
const prevText = this.killRing.peek() || "";
|
||||
this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor);
|
||||
this.cursor -= prevText.length;
|
||||
|
||||
// Rotate and insert new entry
|
||||
this.killRing.rotate();
|
||||
const text = this.killRing.peek() || "";
|
||||
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
|
||||
this.cursor += text.length;
|
||||
this.lastAction = "yank";
|
||||
}
|
||||
|
||||
private pushUndo(): void {
|
||||
this.undoStack.push({ value: this.value, cursor: this.cursor });
|
||||
}
|
||||
|
||||
private undo(): void {
|
||||
const snapshot = this.undoStack.pop();
|
||||
if (!snapshot) return;
|
||||
this.value = snapshot.value;
|
||||
this.cursor = snapshot.cursor;
|
||||
this.lastAction = null;
|
||||
}
|
||||
|
||||
private moveWordBackwards(): void {
|
||||
if (this.cursor === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastAction = null;
|
||||
const textBeforeCursor = this.value.slice(0, this.cursor);
|
||||
const graphemes = [...segmenter.segment(textBeforeCursor)];
|
||||
|
||||
|
|
@ -226,6 +374,7 @@ export class Input implements Component, Focusable {
|
|||
return;
|
||||
}
|
||||
|
||||
this.lastAction = null;
|
||||
const textAfterCursor = this.value.slice(this.cursor);
|
||||
const segments = segmenter.segment(textAfterCursor);
|
||||
const iterator = segments[Symbol.iterator]();
|
||||
|
|
@ -256,6 +405,9 @@ export class Input implements Component, Focusable {
|
|||
}
|
||||
|
||||
private handlePaste(pastedText: string): void {
|
||||
this.lastAction = null;
|
||||
this.pushUndo();
|
||||
|
||||
// Clean the pasted text - remove newlines and carriage returns
|
||||
const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "");
|
||||
|
||||
|
|
|
|||
46
packages/tui/src/kill-ring.ts
Normal file
46
packages/tui/src/kill-ring.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Ring buffer for Emacs-style kill/yank operations.
|
||||
*
|
||||
* Tracks killed (deleted) text entries. Consecutive kills can accumulate
|
||||
* into a single entry. Supports yank (paste most recent) and yank-pop
|
||||
* (cycle through older entries).
|
||||
*/
|
||||
export class KillRing {
|
||||
private ring: string[] = [];
|
||||
|
||||
/**
|
||||
* Add text to the kill ring.
|
||||
*
|
||||
* @param text - The killed text to add
|
||||
* @param opts - Push options
|
||||
* @param opts.prepend - If accumulating, prepend (backward deletion) or append (forward deletion)
|
||||
* @param opts.accumulate - Merge with the most recent entry instead of creating a new one
|
||||
*/
|
||||
push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void {
|
||||
if (!text) return;
|
||||
|
||||
if (opts.accumulate && this.ring.length > 0) {
|
||||
const last = this.ring.pop()!;
|
||||
this.ring.push(opts.prepend ? text + last : last + text);
|
||||
} else {
|
||||
this.ring.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get most recent entry without modifying the ring. */
|
||||
peek(): string | undefined {
|
||||
return this.ring.length > 0 ? this.ring[this.ring.length - 1] : undefined;
|
||||
}
|
||||
|
||||
/** Move last entry to front (for yank-pop cycling). */
|
||||
rotate(): void {
|
||||
if (this.ring.length > 1) {
|
||||
const last = this.ring.pop()!;
|
||||
this.ring.unshift(last);
|
||||
}
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.ring.length;
|
||||
}
|
||||
}
|
||||
28
packages/tui/src/undo-stack.ts
Normal file
28
packages/tui/src/undo-stack.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Generic undo stack with clone-on-push semantics.
|
||||
*
|
||||
* Stores deep clones of state snapshots. Popped snapshots are returned
|
||||
* directly (no re-cloning) since they are already detached.
|
||||
*/
|
||||
export class UndoStack<S> {
|
||||
private stack: S[] = [];
|
||||
|
||||
/** Push a deep clone of the given state onto the stack. */
|
||||
push(state: S): void {
|
||||
this.stack.push(structuredClone(state));
|
||||
}
|
||||
|
||||
/** Pop and return the most recent snapshot, or undefined if empty. */
|
||||
pop(): S | undefined {
|
||||
return this.stack.pop();
|
||||
}
|
||||
|
||||
/** Remove all snapshots. */
|
||||
clear(): void {
|
||||
this.stack.length = 0;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.stack.length;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue