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())