From 35e8222674ffe657eb63d7dd73fe0d78ab6ee794 Mon Sep 17 00:00:00 2001 From: ori Date: Sun, 31 May 2026 21:34:37 +0300 Subject: [PATCH] feat: dG and cG with single-step undo via editBuffer --- AGENTS.md | 9 ++- BACKLOG.md | 2 +- CHANGELOG.md | 9 ++- README.md | 3 +- src/index.ts | 42 +++++++++++ src/vim.ts | 16 ++++- test/vim.test.ts | 182 ++++++++++++++++++++++++++++++++++++++++++++--- 7 files changed, 244 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ea69c7e..4ca0be8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,12 +53,12 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (148 lines) Plugin entry: intercept registration, action application - vim.ts (539 lines) Pure vim engine: state, handlers, command tables, types + index.ts (190 lines) Plugin entry: intercept registration, action application + vim.ts (549 lines) Pure vim engine: state, handlers, command tables, types clipboard.ts (19 lines) writeClipboard() — cross-platform (pbcopy/xclip/xsel/wl-copy/clip.exe) version.ts (46 lines) Version constant, GitHub update check (cached daily) test/ - vim.test.ts (847 lines) Characterization tests for all key handling branches + vim.test.ts (904 lines) Characterization tests for all key handling branches ``` **Data flow:** @@ -81,6 +81,8 @@ Handlers in `vim.ts` are pure — they take state + key + event, mutate state, r - `{ type: "clearSelection" }` — clears the textarea's selection via `editorView.resetSelection()` - `{ type: "cursorTo", offset: number }` — sets `editor.cursorOffset` directly - `{ type: "selectRange", start: number, end: number }` — calls `editor.setSelectionInclusive(start, end)` +- `{ type: "deleteRange", start: number, end: number }` — deletes text between inclusive offsets via `editBuffer.deleteRange()`. Saves a snapshot for single-step undo (see below). +- `{ type: "undo" }` — if an undo snapshot exists (from a `deleteRange`), restores the full buffer from it. Otherwise falls back to `dispatchCommand("input.undo")`. ### Adding a keybinding @@ -112,6 +114,7 @@ To add a new motion that works with operators: ### Known limitations - **`setTimeout` dispatch** — commands are deferred to avoid re-entrancy. Multi-command sequences (like `O` = home + newline + up) rely on ordered setTimeout execution, which works in practice but isn't guaranteed by spec. Many of these can now be replaced with direct widget manipulation (e.g., setting `cursorOffset`, calling `insertText`). +- **editBuffer undo granularity** — the host editor's undo system splits multi-line deletions into per-line entries. Operations that use `deleteRange` (like `dG`, `de`) work around this by saving a pre-operation snapshot and restoring from it on `u`. The snapshot is invalidated when any other buffer-modifying action runs (`cmd` or `insertText`). ## Development diff --git a/BACKLOG.md b/BACKLOG.md index 3f69174..8e4deca 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -30,7 +30,7 @@ Ordered by priority within each category. 2. **Visual-line mode (`V`).** The widget's `getLineInfo()` and `setSelection()` make line-wise selection straightforward. Extend the existing visual mode with a `visual-line` variant. -3. **`dG`/`cG` — delete/change to buffer end.** `yG` already works. Delete and change variants need the same range calculation plus a content write. +3. ~~**`dG`/`cG` — delete/change to buffer end.**~~ Done. 4. **Proper `gg` as go-to-line.** Once `g` waits for a second keypress, `gg` goes to buffer start and `{n}G` goes to line n. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a32f5d..a1dd4eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +### Added + +- `dG` and `cG` — delete/change from cursor to end of buffer. `yG` already worked, now all three operators work with `G`. + ### Changed -- Cursor shape uses the editor widget's `cursorStyle` property instead of writing DECSCUSR escape sequences to stdout. Works in terminals without DECSCUSR support (e.g. macOS Terminal.app). +- Cursor shape is set via the editor widget's `cursorStyle` property instead of writing DECSCUSR escape sequences to stdout. Fixes terminals that don't support DECSCUSR (e.g. macOS Terminal.app). ### Fixed -- `yy` reads the cursor position directly from the editor widget instead of a line counter. The old counter drifted on clicks, arrow keys, and word motions, so `yy` would yank the wrong line. +- `dG`, `cG`, `de`, `ce` undo in a single `u` press. The host editor's undo system splits multi-line deletions into per-line entries, so these operations now go through `editBuffer.deleteRange()` with a pre-operation snapshot that `u` restores from directly. +- `yy` reads the cursor position from the editor widget instead of a line counter. The counter drifted on clicks, arrow keys, and word motions, causing `yy` to yank the wrong line. - `e` moves to end of word instead of behaving like `w`. `de`, `ce`, and `ye` operate on the correct range too. - `g` waits for a second keypress instead of jumping to buffer start on its own. `gg` now works as a proper two-key command. diff --git a/README.md b/README.md index a4d0b29..4221d1f 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ When the input is empty, `j`/`k` scroll through prompt history instead of moving | `dl` `cl` `yl` | Character right | | `dj` `cj` `yj` | Current + line below | | `dk` `ck` `yk` | Current + line above | -| `yG` | To end of buffer (yank only) | +| `dG` `cG` `yG` | To end of buffer | Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3 words. @@ -156,7 +156,6 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b - `V`, `Ctrl+v` - only character-wise visual mode (`v`) is supported, no line-wise or block - `ciw`, `di"`, etc. (text objects) - not yet implemented -- `dG`, `cG` - delete/change to buffer end not yet implemented (`yG` works) - No persistent mode indicator - the toast fades after about a second. Cursor shape is the persistent signal, but a status bar indicator would need the host's SolidJS runtime, which external plugins can't access. Configurable key bindings are next once the core vim coverage stabilizes. diff --git a/src/index.ts b/src/index.ts index 38c47cc..bdabede 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,11 @@ const plugin: TuiPluginModule = { const startMode = options?.startMode === "normal" ? "normal" : "insert"; state.mode = startMode; + // Snapshot for single-step undo of deleteRange operations. + // The host editor's undo system splits multi-line deletions into + // multiple entries, so we save/restore the buffer ourselves. + let undoSnapshot: { text: string; cursor: number } | null = null; + const prompt = { getLine: (n: number) => getInputText().split("\n")[n] ?? "", getLineCount: () => getInputText().split("\n").length, @@ -26,6 +31,11 @@ const plugin: TuiPluginModule = { function applyActions(actions: Action[]) { for (const action of actions) { + // Any buffer-modifying action (other than our own deleteRange/undo) + // invalidates the undo snapshot. + if (action.type === "cmd" || action.type === "insertText") { + undoSnapshot = null; + } switch (action.type) { case "cmd": setTimeout(() => api.keymap.dispatchCommand(action.cmd), 0); @@ -61,6 +71,32 @@ const plugin: TuiPluginModule = { case "clearSelection": api.renderer?.currentFocusedEditor?.editorView?.resetSelection?.(); break; + case "deleteRange": { + const editor = api.renderer?.currentFocusedEditor; + const eb = editor?.editBuffer; + if (eb?.deleteRange) { + undoSnapshot = { text: editor.plainText ?? "", cursor: editor.cursorOffset ?? 0 }; + const text = editor.plainText ?? ""; + const [sl, sc] = offsetToLineCol(text, action.start); + const [el, ec] = offsetToLineCol(text, action.end + 1); + eb.deleteRange(sl, sc, el, ec); + } + break; + } + case "undo": { + if (undoSnapshot) { + const editor = api.renderer?.currentFocusedEditor; + const eb = editor?.editBuffer; + if (eb?.setText && editor) { + eb.setText(undoSnapshot.text); + editor.cursorOffset = undoSnapshot.cursor; + } + undoSnapshot = null; + } else { + setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0); + } + break; + } case "cursorTo": { const editor = api.renderer?.currentFocusedEditor; if (editor) editor.cursorOffset = action.offset; @@ -145,4 +181,10 @@ const plugin: TuiPluginModule = { }, }; +function offsetToLineCol(text: string, offset: number): [number, number] { + const before = text.substring(0, offset); + const lines = before.split("\n"); + return [lines.length - 1, lines[lines.length - 1].length]; +} + export default plugin; diff --git a/src/vim.ts b/src/vim.ts index 14dea58..4117584 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -9,6 +9,8 @@ export type Action = | { type: "insertText"; text: string } | { type: "yankSelection" } | { type: "clearSelection" } + | { type: "deleteRange"; start: number; end: number } + | { type: "undo" } | { type: "cursorTo"; offset: number } | { type: "selectRange"; start: number; end: number }; @@ -301,8 +303,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom actions.push({ type: "yank", text }); resetPending(state); } else { - actions.push({ type: "selectRange", start: offset, end: target }); - actions.push({ type: "cmd", cmd: "input.backspace" }); + actions.push({ type: "deleteRange", start: offset, end: target }); if (state.pendingOp === "c") enterInsert(state, actions); else resetPending(state); } @@ -336,6 +337,15 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom else resetPending(state); return { consume: true, actions }; } + if (key === "G") { + consumeCount(state); + const offset = prompt.getCursorOffset(); + const text = prompt.getPlainText(); + actions.push({ type: "deleteRange", start: offset, end: Math.max(0, text.length - 1) }); + if (state.pendingOp === "c") enterInsert(state, actions); + else resetPending(state); + return { consume: true, actions }; + } const deleteCmd = DELETE_MOTION[key]; if (deleteCmd) { @@ -386,7 +396,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } if (key === "u") { - actions.push({ type: "cmd", cmd: "input.undo" }); + actions.push({ type: "undo" }); resetPending(state); return { consume: true, actions }; } diff --git a/test/vim.test.ts b/test/vim.test.ts index d62f829..21b72a7 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -19,9 +19,9 @@ function cursorTos(actions: Action[]): number[] { return actions.filter((a): a is Extract => a.type === "cursorTo").map((a) => a.offset); } -function selectRanges(actions: Action[]): Array<{ start: number; end: number }> { +function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> { return actions - .filter((a): a is Extract => a.type === "selectRange") + .filter((a): a is Extract => a.type === "deleteRange") .map((a) => ({ start: a.start, end: a.end })); } @@ -290,16 +290,14 @@ describe("handleNormalKey — e motion", () => { it("de deletes from cursor to end of word", () => { handleNormalKey(state, "d", ev("d"), ePrompt); const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - expect(cmds(r.actions)).toContain("input.backspace"); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); expect(state.mode).toBe("normal"); }); it("ce deletes from cursor to end of word and enters insert", () => { handleNormalKey(state, "c", ev("c"), ePrompt); const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - expect(cmds(r.actions)).toContain("input.backspace"); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); expect(state.mode).toBe("insert"); }); @@ -407,6 +405,59 @@ describe("handleNormalKey — operators", () => { }); }); +// ── handleNormalKey — dG and cG ───────────────────────────── + +describe("handleNormalKey — dG and cG", () => { + const midPrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line", "third line"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + getCursorOffset: () => 12, + getPlainText: () => "hello world\nsecond line\nthird line", + }; + + it("dG deletes from cursor to buffer end", () => { + handleNormalKey(state, "d", ev("d"), midPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]); + expect(state.mode).toBe("normal"); + }); + + it("cG deletes from cursor to buffer end, enters insert", () => { + handleNormalKey(state, "c", ev("c"), midPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]); + expect(state.mode).toBe("insert"); + }); + + it("yG still works (no regression)", () => { + handleNormalKey(state, "y", ev("y"), midPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); + expect(cmds(r.actions)).toContain("input.select.buffer.end"); + expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); + }); + + it("dG on empty buffer doesn't crash", () => { + handleNormalKey(state, "d", ev("d"), emptyPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), emptyPrompt); + expect(r.consume).toBe(true); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 0 }]); + }); + + it("dG with cursor at end of buffer", () => { + const endPrompt: PromptAccess = { + getLine: (n) => ["hello"][n] ?? "", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 4, + getPlainText: () => "hello", + }; + handleNormalKey(state, "d", ev("d"), endPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), endPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 4, end: 4 }]); + }); +}); + // ── handleNormalKey — shortcuts ───────────────────────────── describe("handleNormalKey — shortcuts", () => { @@ -465,9 +516,9 @@ describe("handleNormalKey — special keys", () => { expect(cmds(r.actions)).toEqual(["input.line.end", "input.delete"]); }); - it("u dispatches input.undo", () => { + it("u triggers undo", () => { const r = handleNormalKey(state, "u", ev("u"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.undo"]); + expect(r.actions.some((a) => a.type === "undo")).toBe(true); }); it("ctrl+r dispatches input.redo", () => { @@ -845,3 +896,118 @@ describe("plugin init", () => { await plugin.tui(api as any, undefined, undefined as any); }); }); + +// ── undo snapshot integration ───────────────────────────── + +describe("undo snapshot — deleteRange + u", () => { + // Exercises the full pipeline: key event → handler → applyActions → editor state. + // The contract: u after dG restores the full buffer in one step via + // editBuffer.setText, not the host's per-line input.undo. + + function createMockEditor(text: string, cursor: number) { + let editorText = text; + let editorCursor = cursor; + const calls: { method: string; args: unknown[] }[] = []; + const editor = { + get plainText() { + return editorText; + }, + get cursorOffset() { + return editorCursor; + }, + set cursorOffset(v: number) { + editorCursor = v; + }, + visualCursor: { logicalRow: 1 }, + cursorStyle: { style: "block" as const, blinking: true }, + insertText: () => {}, + setSelectionInclusive: () => {}, + editorView: { resetSelection: () => {} }, + editBuffer: { + deleteRange: (sl: number, sc: number, el: number, ec: number) => { + calls.push({ method: "deleteRange", args: [sl, sc, el, ec] }); + editorText = editorText.substring(0, cursor); + }, + setText: (t: string) => { + calls.push({ method: "setText", args: [t] }); + editorText = t; + }, + }, + }; + return { editor, calls, getText: () => editorText, getCursor: () => editorCursor }; + } + + async function setup(text: string, cursor: number) { + const plugin = (await import("../src/index")).default; + const { editor, calls, getText, getCursor } = createMockEditor(text, cursor); + const dispatched: string[] = []; + // biome-ignore lint/suspicious/noExplicitAny: test mock + let handler: (ctx: any) => void; + + const api = { + renderer: { currentFocusedEditor: editor, currentFocusedRenderable: editor }, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { + intercept: (_e: string, h: typeof handler) => { + handler = h; + }, + dispatchCommand: (cmd: string) => { + dispatched.push(cmd); + return { ok: false }; + }, + }, + route: { current: { name: "home", params: {} } }, + state: { session: { question: () => [], permission: () => [] } }, + lifecycle: { onDispose: () => {} }, + kv: {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: mock API + await plugin.tui(api as any, undefined, undefined as any); + + const press = (name: string, opts: Record = {}) => { + handler?.({ event: { name, eventType: "press", ...opts }, consume: () => {} }); + }; + + // Enter normal mode + press("escape"); + + return { press, calls, dispatched, getText, getCursor }; + } + + it("u after dG restores the full buffer via editBuffer.setText", async () => { + const original = "hello world\nsecond line\nthird line"; + const { press, calls, dispatched, getCursor } = await setup(original, 12); + + press("d"); + press("g", { shift: true }); + expect(calls.some((c) => c.method === "deleteRange")).toBe(true); + + calls.length = 0; + press("u"); + + expect(calls).toContainEqual({ method: "setText", args: [original] }); + expect(getCursor()).toBe(12); + expect(dispatched).not.toContain("input.undo"); + }); + + it("u after dG then a motion falls back to host input.undo", async () => { + const { press, calls, dispatched } = await setup("hello world\nsecond line\nthird line", 12); + + press("d"); + press("g", { shift: true }); + expect(calls.some((c) => c.method === "deleteRange")).toBe(true); + + // h dispatches input.move.left (a cmd action), invalidating the snapshot + press("h"); + + calls.length = 0; + dispatched.length = 0; + press("u"); + + expect(calls.every((c) => c.method !== "setText")).toBe(true); + // input.undo is dispatched via setTimeout + await new Promise((r) => setTimeout(r, 20)); + expect(dispatched).toContain("input.undo"); + }); +});