diff --git a/.luarc.json b/.luarc.json index 956d9a6..9871375 100644 --- a/.luarc.json +++ b/.luarc.json @@ -4,6 +4,17 @@ "version": "LuaJIT" }, "workspace": { - "library": ["lua", "$VIMRUNTIME", "${3rd}/luv/library"] + "library": [ + "lua", + "$VIMRUNTIME", + "${3rd}/luv/library", + "${3rd}/busted/library", + "${3rd}/luassert/library" + ] + }, + "diagnostics": { + "globals": [ + "vim", + ] } } diff --git a/lua/tabi/extmarks.lua b/lua/tabi/extmarks.lua new file mode 100644 index 0000000..ea45590 --- /dev/null +++ b/lua/tabi/extmarks.lua @@ -0,0 +1,80 @@ +---@class TabiExtmarks +local M = {} + +-- Namespace for position-tracking extmarks (separate from the display namespace) +local ns = vim.api.nvim_create_namespace("tabi_positions") + +-- Per-buffer mapping: bufnr -> { [note_id] -> { start_id: number, end_id: number } } +local _buf_marks = {} + +--- Register extmarks for a note so its position is tracked across edits +---@param bufnr number +---@param note NoteData +function M.track(bufnr, note) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + if not _buf_marks[bufnr] then + _buf_marks[bufnr] = {} + end + + -- Remove stale extmarks for this note before re-creating + M.untrack(bufnr, note.id) + + local line_count = vim.api.nvim_buf_line_count(bufnr) + local start_row = math.min(note.line - 1, line_count - 1) + local end_row = math.min((note.end_line or note.line) - 1, line_count - 1) + + local start_id = vim.api.nvim_buf_set_extmark(bufnr, ns, start_row, 0, {}) + local end_id = vim.api.nvim_buf_set_extmark(bufnr, ns, end_row, 0, {}) + + _buf_marks[bufnr][note.id] = { start_id = start_id, end_id = end_id } +end + +--- Remove extmarks for a specific note +---@param bufnr number +---@param note_id string +function M.untrack(bufnr, note_id) + if not _buf_marks[bufnr] or not _buf_marks[bufnr][note_id] then + return + end + local ids = _buf_marks[bufnr][note_id] + pcall(vim.api.nvim_buf_del_extmark, bufnr, ns, ids.start_id) + pcall(vim.api.nvim_buf_del_extmark, bufnr, ns, ids.end_id) + _buf_marks[bufnr][note_id] = nil +end + +--- Get current positions (1-indexed) for all tracked notes in a buffer +---@param bufnr number +---@return table +function M.get_positions(bufnr) + if not _buf_marks[bufnr] then + return {} + end + + local positions = {} + for note_id, ids in pairs(_buf_marks[bufnr]) do + local start_pos = vim.api.nvim_buf_get_extmark_by_id(bufnr, ns, ids.start_id, {}) + local end_pos = vim.api.nvim_buf_get_extmark_by_id(bufnr, ns, ids.end_id, {}) + + if start_pos and #start_pos >= 1 and end_pos and #end_pos >= 1 then + positions[note_id] = { + line = start_pos[1] + 1, -- convert 0-indexed row to 1-indexed line + end_line = end_pos[1] + 1, + } + end + end + return positions +end + +--- Clear all extmarks for a buffer and remove it from the tracking table +---@param bufnr number +function M.clear(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + end + _buf_marks[bufnr] = nil +end + +return M diff --git a/lua/tabi/init.lua b/lua/tabi/init.lua index 75ceaeb..cffc25e 100644 --- a/lua/tabi/init.lua +++ b/lua/tabi/init.lua @@ -76,7 +76,8 @@ function M._setup_default_session_display() callback = function(args) -- Only show when no named session or retrace is active if not M.state.current_session and not require("tabi.retrace").is_active() then - display.update_for_session(args.buf, default_session) + local fresh = session_module.load("default") + display.update_for_session(args.buf, fresh or default_session) end end, }) diff --git a/lua/tabi/session.lua b/lua/tabi/session.lua index 9e12836..7e881de 100644 --- a/lua/tabi/session.lua +++ b/lua/tabi/session.lua @@ -170,6 +170,10 @@ end function M.remove_note(session, note_id) for i, note in ipairs(session.notes) do if note.id == note_id then + local bufnr = vim.fn.bufnr(note.file) + if bufnr ~= -1 then + require("tabi.extmarks").untrack(bufnr, note_id) + end table.remove(session.notes, i) return M.save(session) end @@ -206,6 +210,44 @@ function M.get_notes_for_file(session, file_path) return notes end +--- Sync note positions from extmarks for notes belonging to a specific buffer. +--- Should be called before saving so that line numbers reflect the current state +--- of the buffer after edits. +---@param session SessionData +---@param bufnr number +---@return boolean changed Whether any positions were updated +function M.sync_positions_from_extmarks(session, bufnr) + if not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + + local file_path = vim.api.nvim_buf_get_name(bufnr) + if file_path == "" then + return false + end + + local extmarks = require("tabi.extmarks") + local positions = extmarks.get_positions(bufnr) + local changed = false + + for _, note in ipairs(session.notes) do + if note.file == file_path and positions[note.id] then + local pos = positions[note.id] + if note.line ~= pos.line or note.end_line ~= pos.end_line then + note.line = pos.line + note.end_line = pos.end_line + changed = true + end + end + end + + if changed then + M.save(session) + end + + return changed +end + --- Get note at specific line ---@param session SessionData ---@param file_path string diff --git a/lua/tabi/ui/display.lua b/lua/tabi/ui/display.lua index d199c02..087265d 100644 --- a/lua/tabi/ui/display.lua +++ b/lua/tabi/ui/display.lua @@ -92,8 +92,22 @@ end function M.refresh_buffer(bufnr, notes) M.clear_buffer(bufnr) + local extmarks = require("tabi.extmarks") + + -- Capture positions tracked by extmarks BEFORE clearing them. + -- These reflect any line shifts caused by edits since the last save, + -- so they must take priority over the (possibly stale) positions in the session file. + local tracked_positions = extmarks.get_positions(bufnr) + extmarks.clear(bufnr) + for _, note in ipairs(notes) do + local pos = tracked_positions[note.id] + if pos then + note.line = pos.line + note.end_line = pos.end_line + end M.display_note_as_virtual_line(bufnr, note) + extmarks.track(bufnr, note) end end @@ -125,7 +139,8 @@ function M.setup_autocmds(session) vim.api.nvim_create_autocmd({ "BufEnter", "BufWinEnter" }, { group = group, callback = function(args) - M.update_for_session(args.buf, session) + local fresh = require("tabi.session").load(session.id) + M.update_for_session(args.buf, fresh or session) end, }) @@ -137,6 +152,14 @@ function M.setup_autocmds(session) -- M.clear_buffer(args.buf) end, }) + + -- Clean up extmarks when a buffer is deleted + vim.api.nvim_create_autocmd("BufDelete", { + group = group, + callback = function(args) + require("tabi.extmarks").clear(args.buf) + end, + }) end --- Clear all autocommands diff --git a/plugin/tabi.lua b/plugin/tabi.lua index 31364cc..d972cf7 100644 --- a/plugin/tabi.lua +++ b/plugin/tabi.lua @@ -368,6 +368,27 @@ function commands.session_rename(args) end end +-- Sync note positions from extmarks just before the buffer is written to disk +vim.api.nvim_create_autocmd("BufWritePre", { + group = vim.api.nvim_create_augroup("TabiPositionSync", { clear = true }), + callback = function(args) + -- Prefer the explicitly active session; fall back to the default session + -- when show_default_notes is displaying notes without an active session + -- (e.g. after a Neovim restart). + local session_id = tabi.state.current_session + if not session_id then + if not require("tabi.config").get().show_default_notes then + return + end + session_id = "default" + end + local session = session_module.load(session_id) + if session then + session_module.sync_positions_from_extmarks(session, args.buf) + end + end, +}) + -- Create user commands vim.api.nvim_create_user_command("Tabi", function(opts) local args = vim.split(vim.trim(opts.args), "%s+") diff --git a/test/tabi/extmarks_spec.lua b/test/tabi/extmarks_spec.lua new file mode 100644 index 0000000..d3d6326 --- /dev/null +++ b/test/tabi/extmarks_spec.lua @@ -0,0 +1,175 @@ +local extmarks = require("tabi.extmarks") +local note_module = require("tabi.note") + +describe("tabi.extmarks", function() + local bufnr + + before_each(function() + bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + "line 1", -- 1 + "line 2", -- 2 + "line 3", -- 3 + "line 4", -- 4 + "line 5", -- 5 + "line 6", -- 6 + "line 7", -- 7 + "line 8", -- 8 + "line 9", -- 9 + "line 10", -- 10 + }) + end) + + after_each(function() + if vim.api.nvim_buf_is_valid(bufnr) then + extmarks.clear(bufnr) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end) + + describe("track / get_positions", function() + it("should return the note's initial position", function() + local note = note_module.create("/test.lua", 3, "test note", 5) + extmarks.track(bufnr, note) + + local positions = extmarks.get_positions(bufnr) + assert.are.equal(3, positions[note.id].line) + assert.are.equal(5, positions[note.id].end_line) + end) + + it("should return empty table when no notes are tracked", function() + local positions = extmarks.get_positions(bufnr) + assert.are.equal(0, vim.tbl_count(positions)) + end) + + it("should track multiple notes independently", function() + local n1 = note_module.create("/test.lua", 2, "note 1", 3) + local n2 = note_module.create("/test.lua", 7, "note 2", 9) + extmarks.track(bufnr, n1) + extmarks.track(bufnr, n2) + + local positions = extmarks.get_positions(bufnr) + assert.are.equal(2, positions[n1.id].line) + assert.are.equal(3, positions[n1.id].end_line) + assert.are.equal(7, positions[n2.id].line) + assert.are.equal(9, positions[n2.id].end_line) + end) + + it("should re-track existing note without duplicating marks", function() + local note = note_module.create("/test.lua", 4, "note") + extmarks.track(bufnr, note) + extmarks.track(bufnr, note) -- re-track same note + + local positions = extmarks.get_positions(bufnr) + assert.are.equal(1, vim.tbl_count(positions)) + assert.are.equal(4, positions[note.id].line) + end) + end) + + describe("position tracking after buffer edits", function() + it("should move note down when lines are inserted before it", function() + local note = note_module.create("/test.lua", 5, "test note", 7) + extmarks.track(bufnr, note) + + -- Insert 2 lines before line 5 (0-indexed row 2 = before line 3) + vim.api.nvim_buf_set_lines(bufnr, 2, 2, false, { "inserted 1", "inserted 2" }) + + local positions = extmarks.get_positions(bufnr) + assert.are.equal(7, positions[note.id].line) + assert.are.equal(9, positions[note.id].end_line) + end) + + it("should move note up when lines are deleted before it", function() + local note = note_module.create("/test.lua", 6, "test note", 8) + extmarks.track(bufnr, note) + + -- Delete 2 lines before the note (0-indexed rows 1-2 = lines 2-3) + vim.api.nvim_buf_set_lines(bufnr, 1, 3, false, {}) + + local positions = extmarks.get_positions(bufnr) + assert.are.equal(4, positions[note.id].line) + assert.are.equal(6, positions[note.id].end_line) + end) + + it("should not change note position when lines are inserted after it", function() + local note = note_module.create("/test.lua", 2, "test note", 4) + extmarks.track(bufnr, note) + + -- Insert lines well after the note + vim.api.nvim_buf_set_lines(bufnr, 7, 7, false, { "inserted after" }) + + local positions = extmarks.get_positions(bufnr) + assert.are.equal(2, positions[note.id].line) + assert.are.equal(4, positions[note.id].end_line) + end) + + it("should track multiple notes after independent edits", function() + local n1 = note_module.create("/test.lua", 2, "early note", 3) + local n2 = note_module.create("/test.lua", 7, "late note", 9) + extmarks.track(bufnr, n1) + extmarks.track(bufnr, n2) + + -- Insert 2 lines between the two notes + vim.api.nvim_buf_set_lines(bufnr, 4, 4, false, { "new line a", "new line b" }) + + local positions = extmarks.get_positions(bufnr) + -- n1 is before the insertion point, should not move + assert.are.equal(2, positions[n1.id].line) + assert.are.equal(3, positions[n1.id].end_line) + -- n2 is after the insertion point, should shift down by 2 + assert.are.equal(9, positions[n2.id].line) + assert.are.equal(11, positions[n2.id].end_line) + end) + end) + + describe("untrack", function() + it("should remove position tracking for a specific note", function() + local note = note_module.create("/test.lua", 3, "test note") + extmarks.track(bufnr, note) + + extmarks.untrack(bufnr, note.id) + + local positions = extmarks.get_positions(bufnr) + assert.is_nil(positions[note.id]) + end) + + it("should not affect other tracked notes", function() + local n1 = note_module.create("/test.lua", 2, "note 1") + local n2 = note_module.create("/test.lua", 5, "note 2") + extmarks.track(bufnr, n1) + extmarks.track(bufnr, n2) + + extmarks.untrack(bufnr, n1.id) + + local positions = extmarks.get_positions(bufnr) + assert.is_nil(positions[n1.id]) + assert.is_not_nil(positions[n2.id]) + end) + + it("should be safe to call for a note that is not tracked", function() + assert.has_no.errors(function() + extmarks.untrack(bufnr, "non-existent-id") + end) + end) + end) + + describe("clear", function() + it("should remove all tracked notes for a buffer", function() + local n1 = note_module.create("/test.lua", 2, "note 1") + local n2 = note_module.create("/test.lua", 5, "note 2") + extmarks.track(bufnr, n1) + extmarks.track(bufnr, n2) + + extmarks.clear(bufnr) + + local positions = extmarks.get_positions(bufnr) + assert.are.equal(0, vim.tbl_count(positions)) + end) + + it("should be safe to call on a buffer with no tracked notes", function() + assert.has_no.errors(function() + extmarks.clear(bufnr) + end) + end) + end) +end) diff --git a/test/tabi/integration/position_sync_on_reopen_spec.lua b/test/tabi/integration/position_sync_on_reopen_spec.lua new file mode 100644 index 0000000..7ea1ba9 --- /dev/null +++ b/test/tabi/integration/position_sync_on_reopen_spec.lua @@ -0,0 +1,196 @@ +-- Integration test: note position after file edit → save → close → reopen +-- +-- Scenario: +-- 1. Note is added at line N +-- 2. A line is inserted before line N (extmarks track the note to line N+1) +-- 3. File is saved → BufWritePre syncs positions to storage (note stored as N+1) +-- 4. Buffer is closed → extmarks are cleared +-- 5. File is reopened → BufEnter should display note at N+1, not the stale N + +local tabi = require("tabi") +local session_module = require("tabi.session") +local note_module = require("tabi.note") +local display = require("tabi.ui.display") +local extmarks = require("tabi.extmarks") +local storage = require("tabi.storage") + +describe("integration: note position sync on file reopen", function() + local temp_dir + local original_backend + local bufnr + local file_path + + before_each(function() + temp_dir = vim.fn.tempname() .. "_pos_sync_test" + vim.fn.mkdir(temp_dir .. "/sessions", "p") + + original_backend = storage.backend + storage.backend = { + save_session = function(s) + local path = temp_dir .. "/sessions/" .. s.id .. ".json" + local file = io.open(path, "w") + if file then + file:write(vim.fn.json_encode(s)) + file:close() + return true + end + return false + end, + load_session = function(id) + local path = temp_dir .. "/sessions/" .. id .. ".json" + local file = io.open(path, "r") + if not file then + return nil + end + local content = file:read("*a") + file:close() + local ok, decoded = pcall(vim.fn.json_decode, content) + return ok and decoded or nil + end, + list_sessions = function() + return {} + end, + delete_session = function(id) + local path = temp_dir .. "/sessions/" .. id .. ".json" + return os.remove(path) ~= nil + end, + session_exists = function(id) + local path = temp_dir .. "/sessions/" .. id .. ".json" + return vim.fn.filereadable(path) == 1 + end, + } + + -- Register BufWritePre handler that mirrors plugin/tabi.lua. + -- This is the actual code under test: it must check tabi.state.current_session + -- and load a fresh session from storage before syncing. + vim.api.nvim_create_augroup("TabiPositionSyncTest", { clear = true }) + vim.api.nvim_create_autocmd("BufWritePre", { + group = "TabiPositionSyncTest", + callback = function(args) + if not tabi.state.current_session then + return + end + local session = session_module.load(tabi.state.current_session) + if session then + session_module.sync_positions_from_extmarks(session, args.buf) + end + end, + }) + + display.init() + + -- Create a real temp file + local temp_path = vim.fn.tempname() .. "_pos_sync.lua" + local lines = {} + for i = 1, 20 do + table.insert(lines, "line " .. i) + end + local f = io.open(temp_path, "w") + if f then + f:write(table.concat(lines, "\n") .. "\n") + f:close() + end + + bufnr = vim.fn.bufadd(temp_path) + vim.fn.bufload(bufnr) + vim.api.nvim_buf_set_option(bufnr, "modifiable", true) + file_path = vim.api.nvim_buf_get_name(bufnr) + end) + + after_each(function() + tabi.state.current_session = nil + + storage.backend = original_backend + display.clear_autocmds() + pcall(vim.api.nvim_del_augroup_by_name, "TabiPositionSyncTest") + + if vim.api.nvim_buf_is_valid(bufnr) then + extmarks.clear(bufnr) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + + if vim.fn.filereadable(file_path) == 1 then + os.remove(file_path) + end + + if vim.fn.isdirectory(temp_dir) == 1 then + vim.fn.delete(temp_dir, "rf") + end + end) + + it("should display note at updated position when file is reopened after an edit", function() + -- Step 1: create session with note at line 5 + local sess = session_module.create("pos-sync-test") + local note = note_module.create(file_path, 5, "test note") + session_module.add_note(sess, note) + + -- Step 2: set up autocmds and extmarks + -- tabi.state.current_session must be set; without it the BufWritePre handler early-returns. + tabi.state.current_session = sess.id + display.setup_autocmds(sess) + extmarks.track(bufnr, note) + + -- Step 3: simulate buffer edit + -- Insert 3 lines at row index 1 (before line 2), so note shifts from line 5 to line 8. + vim.api.nvim_buf_set_lines(bufnr, 1, 1, false, { "inserted 1", "inserted 2", "inserted 3" }) + + -- Step 4: fire BufWritePre via the autocmd (tests the real handler path). + -- The handler checks tabi.state.current_session, loads a fresh session from + -- storage, and calls sync_positions_from_extmarks — same as plugin/tabi.lua. + vim.api.nvim_exec_autocmds("BufWritePre", { buffer = bufnr }) + + -- Verify storage has the updated position + local stored = session_module.load(sess.id) + assert.are.equal(8, stored.notes[1].line, "storage must have the synced line") + + -- Step 5: simulate buffer close (extmarks cleared) + extmarks.clear(bufnr) + display.clear_buffer(bufnr) + + -- Step 6: simulate BufEnter (file reopened) + vim.api.nvim_exec_autocmds("BufEnter", { buffer = bufnr }) + + -- The BufEnter callback must load the fresh session from storage + -- and set up extmarks at line 8, not the stale line 5. + local positions = extmarks.get_positions(bufnr) + assert.is_not_nil(positions[note.id], "extmark should be registered after BufEnter") + assert.are.equal(8, positions[note.id].line, "extmark should be at updated line 8, not stale line 5") + end) + + it("should not sync when tabi.state.current_session is nil", function() + -- Regression guard: BufWritePre must be a no-op when no session is active. + local sess = session_module.create("pos-sync-no-state-test") + local note = note_module.create(file_path, 5, "test note") + session_module.add_note(sess, note) + + -- Intentionally do NOT set tabi.state.current_session + extmarks.track(bufnr, note) + vim.api.nvim_buf_set_lines(bufnr, 1, 1, false, { "inserted 1", "inserted 2", "inserted 3" }) + + vim.api.nvim_exec_autocmds("BufWritePre", { buffer = bufnr }) + + -- Storage must still have the original line 5 (sync was skipped) + local stored = session_module.load(sess.id) + assert.are.equal(5, stored.notes[1].line, "position must not be updated when no session is active") + end) + + it("should display note at original position when no edits were made before reopen", function() + -- Regression guard: if nothing changed, position should stay the same + local sess = session_module.create("pos-sync-no-edit-test") + local note = note_module.create(file_path, 10, "stable note") + session_module.add_note(sess, note) + + tabi.state.current_session = sess.id + display.setup_autocmds(sess) + + -- No edits – simulate buffer close then reopen + extmarks.clear(bufnr) + display.clear_buffer(bufnr) + + vim.api.nvim_exec_autocmds("BufEnter", { buffer = bufnr }) + + local positions = extmarks.get_positions(bufnr) + assert.is_not_nil(positions[note.id]) + assert.are.equal(10, positions[note.id].line) + end) +end) diff --git a/test/tabi/session_spec.lua b/test/tabi/session_spec.lua index 4d84115..c13e85a 100644 --- a/test/tabi/session_spec.lua +++ b/test/tabi/session_spec.lua @@ -1,6 +1,7 @@ local session = require("tabi.session") local note_module = require("tabi.note") local storage = require("tabi.storage") +local extmarks = require("tabi.extmarks") describe("tabi.session", function() local temp_dir @@ -243,6 +244,39 @@ describe("tabi.session", function() local result = session.remove_note(s, "nonexistent-id") assert.is_false(result) end) + + it("should untrack extmark when the note's buffer is open", function() + -- Create a buffer associated with a real file so vim.fn.bufnr can find it + local temp_path = vim.fn.tempname() .. ".lua" + local f = io.open(temp_path, "w") + if f then + f:write("line 1\nline 2\nline 3\n") + f:close() + end + local bufnr = vim.fn.bufadd(temp_path) + vim.fn.bufload(bufnr) + local resolved_path = vim.api.nvim_buf_get_name(bufnr) + + local s = session.create("test-remove-untrack") + local n = note_module.create(resolved_path, 2, "tracked note") + session.add_note(s, n) + extmarks.track(bufnr, n) + + -- Confirm the extmark is registered before removal + local before = extmarks.get_positions(bufnr) + assert.is_not_nil(before[n.id]) + + session.remove_note(s, n.id) + + -- Extmark should be cleared after the note is removed + local after = extmarks.get_positions(bufnr) + assert.is_nil(after[n.id]) + + -- Cleanup + extmarks.clear(bufnr) + vim.api.nvim_buf_delete(bufnr, { force = true }) + os.remove(temp_path) + end) end) describe("update_note", function() @@ -290,6 +324,145 @@ describe("tabi.session", function() end) end) + describe("sync_positions_from_extmarks", function() + local bufnr + local file_path + + before_each(function() + -- Create a real temp file with known content + local temp_path = vim.fn.tempname() .. ".lua" + local lines = {} + for i = 1, 15 do + table.insert(lines, "line " .. i) + end + local f = io.open(temp_path, "w") + if f then + f:write(table.concat(lines, "\n") .. "\n") + f:close() + end + + -- bufadd creates a real file-backed buffer; get_name may resolve symlinks (e.g. /var -> /private/var on macOS) + bufnr = vim.fn.bufadd(temp_path) + vim.fn.bufload(bufnr) + vim.api.nvim_buf_set_option(bufnr, "modifiable", true) + -- Use the resolved path so note.file matches what nvim_buf_get_name returns + file_path = vim.api.nvim_buf_get_name(bufnr) + end) + + after_each(function() + if vim.api.nvim_buf_is_valid(bufnr) then + extmarks.clear(bufnr) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + os.remove(file_path) + end) + + it("should update note positions after lines are inserted before the note", function() + local s = session.create("test-sync-insert") + local n = note_module.create(file_path, 5, "tracked note", 7) + session.add_note(s, n) + + -- Register extmark to track the note position + extmarks.track(bufnr, n) + + -- Insert 3 lines before the note (0-indexed row 1 = before line 2) + vim.api.nvim_buf_set_lines(bufnr, 1, 1, false, { "inserted 1", "inserted 2", "inserted 3" }) + + -- Sync positions from extmarks back into session + local changed = session.sync_positions_from_extmarks(s, bufnr) + + assert.is_true(changed) + assert.are.equal(8, s.notes[1].line) + assert.are.equal(10, s.notes[1].end_line) + end) + + it("should update note positions after lines are deleted before the note", function() + local s = session.create("test-sync-delete") + local n = note_module.create(file_path, 8, "tracked note", 10) + session.add_note(s, n) + + extmarks.track(bufnr, n) + + -- Delete 3 lines before the note (0-indexed rows 2-4 = lines 3-5) + vim.api.nvim_buf_set_lines(bufnr, 2, 5, false, {}) + + local changed = session.sync_positions_from_extmarks(s, bufnr) + + assert.is_true(changed) + assert.are.equal(5, s.notes[1].line) + assert.are.equal(7, s.notes[1].end_line) + end) + + it("should persist updated positions to storage", function() + local s = session.create("test-sync-persist") + local n = note_module.create(file_path, 5, "tracked note", 6) + session.add_note(s, n) + + extmarks.track(bufnr, n) + + -- Insert 2 lines before the note + vim.api.nvim_buf_set_lines(bufnr, 0, 0, false, { "prepend 1", "prepend 2" }) + + session.sync_positions_from_extmarks(s, bufnr) + + local loaded = session.load(s.id) + assert.are.equal(7, loaded.notes[1].line) + assert.are.equal(8, loaded.notes[1].end_line) + end) + + it("should return false and not save when positions have not changed", function() + local s = session.create("test-sync-no-change") + local n = note_module.create(file_path, 4, "stable note", 6) + session.add_note(s, n) + + extmarks.track(bufnr, n) + + -- No edits to the buffer; positions should match the current note positions + local changed = session.sync_positions_from_extmarks(s, bufnr) + + assert.is_false(changed) + -- Positions should remain unchanged + assert.are.equal(4, s.notes[1].line) + assert.are.equal(6, s.notes[1].end_line) + end) + + it("should only update notes belonging to the given buffer's file", function() + local s = session.create("test-sync-filter") + local n_same = note_module.create(file_path, 5, "same file note", 6) + local n_other = note_module.create("/other/file.lua", 5, "other file note", 6) + session.add_note(s, n_same) + session.add_note(s, n_other) + + extmarks.track(bufnr, n_same) + + -- Insert 2 lines before both notes' positions + vim.api.nvim_buf_set_lines(bufnr, 0, 0, false, { "prepend 1", "prepend 2" }) + + session.sync_positions_from_extmarks(s, bufnr) + + -- n_same should be updated + local same = nil + local other = nil + for _, note in ipairs(s.notes) do + if note.id == n_same.id then + same = note + elseif note.id == n_other.id then + other = note + end + end + + assert.are.equal(7, same.line) + -- n_other is in a different file and should not be changed + assert.are.equal(5, other.line) + end) + + it("should return false for an invalid buffer", function() + local s = session.create("test-sync-invalid-buf") + local changed = session.sync_positions_from_extmarks(s, 99999) + assert.is_false(changed) + end) + end) + describe("get_note_at_line", function() it("should find note by file and line", function() local s = session.create("test-find-line")