Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
65 changes: 42 additions & 23 deletions lua/toolpath/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions test/test_internals.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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())