From a21f5fdd851c6c013f449031e3527e9c14018ef3 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Tue, 24 Feb 2026 16:00:45 -0500 Subject: [PATCH] feat: gap replay on scratch buffer, never modify tracked buffer Use wundo/rundo to clone the undo tree into a hidden scratch buffer for gap replay instead of running undo commands on the tracked buffer. This prevents interference with LSP, other autocmds, and syntax highlighting during intermediate step capture. Remove the now-unnecessary `replaying` re-entrancy guard. --- CLAUDE.md | 11 +++++-- lua/toolpath/init.lua | 65 ++++++++++++++++++++++++++--------------- test/test_internals.lua | 19 ++++++++++++ 3 files changed, 70 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa0a0f6..a3d9836 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ and their args are documented in `local/track-source-annotate.md` and - `init` — creates session, returns session path on stdout. Accepts `--base-uri` and `--base-ref` for VCS anchoring. - `step` — records an edit. Accepts `--source` (JSON vcsSource). Async in - normal flow, sync during gap replay. + normal flow, sync during gap replay (on scratch buffer). - `visit` — caches content for undo/redo navigation. No `--source`. - `note` — sets intent on head step. Still works, not deprecated. - `annotate` — generalized note. Can target any step, set intent, source, refs. @@ -72,10 +72,17 @@ and `--source` JSON. This keeps the CLI editor-agnostic and VCS-agnostic. `ShellCmdPost` trigger cache refresh. Never run git commands in the `on_text_changed` hot path. +**Never modify buffer contents.** The plugin is a passive observer — it reads +buffer text via `nvim_buf_get_lines` but must never use `vim.cmd("undo")`, +`nvim_buf_set_lines`, or any other API that mutates a tracked buffer. Gap +replay uses a hidden scratch buffer with `wundo`/`rundo` to clone the undo +tree. This prevents interference with the user's editing, LSP, and other +plugins. + **Async for step/visit, sync for everything else.** The `on_text_changed` handler enqueues step and visit calls on a per-buffer serial queue using `vim.system()` callbacks. Gap replay (intermediate undo entries) is sync -because it moves the buffer through undo states. `init`, `close`, `note`, +because it replays on a scratch buffer. `init`, `close`, `note`, `annotate`, and `export` are sync. **Stop order matters.** `M.stop()` must: (1) delete the augroup first to diff --git a/lua/toolpath/init.lua b/lua/toolpath/init.lua index c835d63..461e83e 100644 --- a/lua/toolpath/init.lua +++ b/lua/toolpath/init.lua @@ -5,7 +5,7 @@ local M = {} -M.version = "0.2.0" +M.version = "0.3.0" -- Per-buffer state: { session_path, last_seq, async_queue, step_count, ... } local buf_state = {} @@ -272,7 +272,6 @@ end local function on_text_changed(bufnr) local state = buf_state[bufnr] if not state then return end - if state.replaying then return end local tree = vim.fn.undotree() local cur_seq = tree.seq_cur or 0 @@ -293,28 +292,46 @@ local function on_text_changed(bufnr) -- Check for intermediate undo entries (TextChanged batched > 1 entry) local gap = cur_seq - prev_seq if gap > 1 then - state.replaying = true local parent_map = build_parent_map(tree.entries) - for seq = prev_seq + 1, cur_seq - 1 do - vim.cmd("silent! undo " .. seq) - local content = get_buffer_text(bufnr) - local step_args = { - "step", - "--session", state.session_path, - "--seq", tostring(seq), - "--parent-seq", tostring(parent_map[seq] or prev_seq), - } - if source_json then - table.insert(step_args, "--source") - table.insert(step_args, source_json) + -- Clone undo tree to hidden scratch buffer to avoid modifying tracked buffer + local undo_tmp = vim.fn.tempname() + local ok_wundo = pcall(vim.cmd, "wundo! " .. vim.fn.fnameescape(undo_tmp)) + if ok_wundo then + local orig_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local scratch = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(scratch, 0, -1, false, orig_lines) + local ok_rundo = pcall(function() + vim.api.nvim_buf_call(scratch, function() + vim.cmd("silent rundo " .. vim.fn.fnameescape(undo_tmp)) + end) + end) + if ok_rundo then + local saved_ei = vim.o.eventignore + vim.o.eventignore = "all" + for seq = prev_seq + 1, cur_seq - 1 do + vim.api.nvim_buf_call(scratch, function() + vim.cmd("silent! undo " .. seq) + end) + local content = get_buffer_text(scratch) + local step_args = { + "step", + "--session", state.session_path, + "--seq", tostring(seq), + "--parent-seq", tostring(parent_map[seq] or prev_seq), + } + if source_json then + table.insert(step_args, "--source") + table.insert(step_args, source_json) + end + run_cmd(step_args, content) + state.step_seqs[seq] = true + state.step_count = state.step_count + 1 + end + vim.o.eventignore = saved_ei end - run_cmd(step_args, content) - state.step_seqs[seq] = true - state.step_count = state.step_count + 1 + vim.api.nvim_buf_delete(scratch, { force = true }) end - -- Restore to current seq - vim.cmd("silent! undo " .. cur_seq) - state.replaying = false + os.remove(undo_tmp) end -- Record the current edit (async) @@ -415,13 +432,15 @@ function M.start(actor_override, quiet) repo_root = repo_root, -- nil if file is not in a git repo last_seq = tree.seq_cur or 0, last_seq_last = tree.seq_last or 0, - step_seqs = {}, -- seqs that have recorded steps (for ancestor lookup) + step_seqs = {}, -- seqs with recorded steps; seq 0 pending CLI root-step support step_count = 0, -- total steps recorded in this session - replaying = false, -- guard against re-entrancy during gap replay async_queue = {}, -- serial queue for async CLI calls queue_running = false, } + -- TODO(cli): Once `init` creates a root step at seq 0, enable: + -- buf_state[bufnr].step_seqs[0] = true + set_buf_vars(bufnr, buf_state[bufnr]) -- Set up autocommands for this buffer diff --git a/test/test_internals.lua b/test/test_internals.lua index d314a4f..8610012 100644 --- a/test/test_internals.lua +++ b/test/test_internals.lua @@ -87,4 +87,23 @@ T.test("find_step_ancestor: target seq is 0 (initial state)", function() T.eq(I.find_step_ancestor(parent_map, 1, step_seqs), 0) end) +T.test("find_step_ancestor: second branch from seq 0 returns 0 (orphan)", function() + -- Undo tree: 0 → 1 → 2, 0 → 3 (two branches from initial state) + local parent_map = { [1] = 0, [2] = 1, [3] = 0 } + local step_seqs = { [1] = true } -- step at seq 1, none at seq 0 + -- Seq 3 walks up: parent=0, step_seqs[0]=nil, parent==0 → returns 0 + -- This creates an orphan in the Path document. Fixed when CLI creates + -- a root step at seq 0 on init (step_seqs[0] = true). + T.eq(I.find_step_ancestor(parent_map, 3, step_seqs), 0) +end) + +T.test("find_step_ancestor: root step at seq 0 connects second branch", function() + -- Same undo tree, but with CLI root-step support (step_seqs[0] = true). + -- Return value is the same (0), but the CLI will map seq 0 to step-000 + -- instead of "" — the step has a real parent and the graph is connected. + local parent_map = { [1] = 0, [2] = 1, [3] = 0 } + local step_seqs = { [0] = true, [1] = true } + T.eq(I.find_step_ancestor(parent_map, 3, step_seqs), 0) +end) + os.exit(T.run())