From 9734419fd9e263209d2b828c68f19b2f2a2592b9 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 29 Sep 2026 15:59:27 -0700 Subject: [PATCH 01/13] Keep comments across buffer deletion and reattach by source --- lua/scratch_comments/actions.lua | 6 +- lua/scratch_comments/comments.lua | 149 ++++++++++++++++++++++++--- lua/scratch_comments/list.lua | 2 +- lua/scratch_comments/model/store.lua | 11 +- lua/scratch_comments/model/views.lua | 17 ++- lua/scratch_comments/ui/card.lua | 5 +- tests/features/anchoring_spec.lua | 76 +++++++++++++- 7 files changed, 240 insertions(+), 26 deletions(-) diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua index 7287b13..59135a3 100644 --- a/lua/scratch_comments/actions.lua +++ b/lua/scratch_comments/actions.lua @@ -76,7 +76,8 @@ end ---@param file_path string ---@param where ScratchLocation local function edit_new(bufnr, file_path, where) - local relative_path = paths.relative_path(paths.git_root(file_path), file_path) + local relative_path = file_path:match("^%w[%w+.-]*://") and file_path + or paths.relative_path(paths.git_root(file_path), file_path) ---@type ScratchComment? local added ---@type ScratchCommentWindow @@ -102,6 +103,7 @@ local function edit_new(bufnr, file_path, where) bufnr = bufnr, comment = text, file_path = file_path, + source_name = file_path, relative_path = relative_path, }, where) record.id = added.id @@ -137,7 +139,7 @@ function M.comment(start_line, end_line, use_selection) return end - edit_new(bufnr, vim.fn.fnamemodify(name, ":p"), where) + edit_new(bufnr, name:match("^%w[%w+.-]*://") and name or vim.fn.fnamemodify(name, ":p"), where) end ---@param view ScratchCommentView diff --git a/lua/scratch_comments/comments.lua b/lua/scratch_comments/comments.lua index 4d44934..4464442 100644 --- a/lua/scratch_comments/comments.lua +++ b/lua/scratch_comments/comments.lua @@ -1,26 +1,32 @@ local anchors = require("scratch_comments.model.anchors") local signs = require("scratch_comments.ui.signs") local store = require("scratch_comments.model.store") +local views = require("scratch_comments.model.views") local M = {} local group = vim.api.nvim_create_augroup("scratch_comments", { clear = true }) +local watched = {} ---@param bufnr integer function M.redraw(bufnr) - signs.draw(bufnr, store.in_buffer(bufnr)) + if vim.api.nvim_buf_is_valid(bufnr) and vim.api.nvim_buf_is_loaded(bufnr) then + signs.draw(bufnr, store.in_buffer(bufnr)) + end end ---@return integer[] local function commented_buffers() local seen = {} for _, comment in ipairs(store.all()) do - seen[comment.bufnr] = true + if comment.bufnr then + seen[comment.bufnr] = true + end end return vim.tbl_keys(seen) end ----@param fields { bufnr: integer, comment: string, file_path: string, relative_path: string } +---@param fields { bufnr: integer, comment: string, file_path: string, source_name?: string, relative_path: string } ---@param location ScratchLocation ---@return ScratchComment function M.add(fields, location) @@ -29,6 +35,18 @@ function M.add(fields, location) comment = fields.comment, file_path = fields.file_path, relative_path = fields.relative_path, + source_name = fields.source_name, + last_location = vim.deepcopy(location), + snippet = table.concat( + views.text( + fields.bufnr, + location.start_line, + location.end_line, + location.start_col, + location.end_col + ), + "\n" + ), charwise = location.start_col ~= nil, }) anchors.anchor( @@ -38,7 +56,7 @@ function M.add(fields, location) location.start_col, location.end_col ) - M.redraw(comment.bufnr) + M.redraw(fields.bufnr) return comment end @@ -54,7 +72,9 @@ function M.delete(comment) anchors.clear_all(store.remove(function(candidate) return candidate.id == comment.id end)) - M.redraw(comment.bufnr) + if comment.bufnr then + M.redraw(comment.bufnr) + end end function M.clear() @@ -65,13 +85,70 @@ function M.clear() store.clear() end +-- Snapshot while extmarks and text still exist. BufUnload, BufDelete and +-- BufWipeout can all fire for the same buffer; only the first does work. ---@param bufnr integer -function M.forget_buffer(bufnr) - anchors.clear_buffer(bufnr) - signs.clear_buffer(bufnr) - store.remove(function(comment) - return comment.bufnr == bufnr - end) +function M.park_buffer(bufnr) + for _, comment in ipairs(store.in_buffer(bufnr)) do + local start_line, end_line, start_col, end_col = anchors.range(comment) + if start_line then + end_line = assert(end_line) + comment.last_location = { + start_line = start_line, + end_line = end_line, + start_col = start_col, + end_col = end_col, + } + comment.snippet = + table.concat(views.text(bufnr, start_line, end_line, start_col, end_col), "\n") + comment.state = "parked" + else + comment.state = "inactive" + end + comment.bufnr, comment.extmark_id = nil, nil + end +end + +-- An active source may reopen under a different bufnr. Its old line number is +-- only trusted when the saved text still occupies that exact range. +---@param bufnr integer +function M.reattach(bufnr) + if not vim.api.nvim_buf_is_loaded(bufnr) then + return + end + local name = vim.api.nvim_buf_get_name(bufnr) + -- BufReadCmd providers can return from the command before asynchronously + -- supplying any text. The initial empty line is not a completed read. + if name:match("^%w[%w+.-]*://") then + local first = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] + if first == "" and vim.api.nvim_buf_line_count(bufnr) == 1 then + return + end + end + local changed = false + for _, comment in ipairs(store.all()) do + if comment.source_name == name and comment.state == "parked" then + local where = comment.last_location + local count = vim.api.nvim_buf_line_count(bufnr) + if where.end_line <= count then + local ok, text = + pcall(views.text, bufnr, where.start_line, where.end_line, where.start_col, where.end_col) + if ok and table.concat(text, "\n") == comment.snippet then + comment.bufnr = bufnr + anchors.anchor(comment, where.start_line, where.end_line, where.start_col, where.end_col) + comment.state = nil + changed = true + else + comment.state = "inactive" + end + else + comment.state = "inactive" + end + end + end + if changed then + M.redraw(bufnr) + end end ---@param on boolean @@ -89,10 +166,56 @@ vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { end, }) -vim.api.nvim_create_autocmd("BufDelete", { +vim.api.nvim_create_autocmd({ "BufUnload", "BufDelete", "BufWipeout" }, { group = group, callback = function(args) - M.forget_buffer(args.buf) + M.park_buffer(args.buf) + end, +}) + +-- A read has completed for normal files. BufReadCmd sources may instead fill +-- the buffer later; watch their text changes without depending on the provider. +vim.api.nvim_create_autocmd("BufReadPost", { + group = group, + callback = function(args) + vim.schedule(function() + if vim.api.nvim_buf_is_valid(args.buf) then + M.reattach(args.buf) + end + end) + end, +}) + +vim.api.nvim_create_autocmd("BufEnter", { + group = group, + callback = function(args) + local bufnr = args.buf + if vim.api.nvim_buf_is_loaded(bufnr) then + if not watched[bufnr] then + watched[bufnr] = vim.api.nvim_buf_attach(bufnr, false, { + on_lines = function() + vim.schedule(function() + if vim.api.nvim_buf_is_valid(bufnr) then + M.reattach(bufnr) + end + end) + end, + on_detach = function() + watched[bufnr] = nil + end, + }) + end + -- A previously loaded file can be reentered without a read event. For + -- URI buffers an initial empty pane may still be waiting for BufReadCmd. + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false) + if not vim.api.nvim_buf_get_name(bufnr):match("^%w[%w+.-]*://") or lines[1] ~= "" then + vim.schedule(function() + if vim.api.nvim_buf_is_valid(bufnr) then + M.reattach(bufnr) + end + end) + end + end end, }) diff --git a/lua/scratch_comments/list.lua b/lua/scratch_comments/list.lua index da5ba03..7810b8f 100644 --- a/lua/scratch_comments/list.lua +++ b/lua/scratch_comments/list.lua @@ -54,7 +54,7 @@ function M.open(filter) local entry = index and listed[index] if not entry then preview.close() - elseif entry.snippet then + elseif entry.start_line then preview.show(card.of(entry)) else preview.show(card.of_orphan(entry)) diff --git a/lua/scratch_comments/model/store.lua b/lua/scratch_comments/model/store.lua index dbb4641..60e64de 100644 --- a/lua/scratch_comments/model/store.lua +++ b/lua/scratch_comments/model/store.lua @@ -2,8 +2,12 @@ local M = {} ---@class ScratchComment ---@field id string ----@field bufnr integer +---@field bufnr? integer ---@field extmark_id? integer +---@field source_name string +---@field last_location ScratchLocation +---@field snippet string +---@field state? "parked"|"inactive" nil while attached ---@field comment string ---@field file_path string ---@field relative_path string @@ -13,12 +17,15 @@ local M = {} local comments = {} local next_id = 1 ----@param fields { bufnr: integer, comment: string, file_path: string, relative_path: string, charwise?: boolean } +---@param fields { bufnr: integer, comment: string, file_path: string, relative_path: string, source_name?: string, last_location?: ScratchLocation, snippet?: string, charwise?: boolean } ---@return ScratchComment function M.add(fields) local comment = { id = "comment-" .. next_id, bufnr = fields.bufnr, + source_name = fields.source_name or fields.file_path, + last_location = fields.last_location or { start_line = 1, end_line = 1 }, + snippet = fields.snippet or "", comment = fields.comment, file_path = fields.file_path, relative_path = fields.relative_path, diff --git a/lua/scratch_comments/model/views.lua b/lua/scratch_comments/model/views.lua index ce2731e..98f4bbd 100644 --- a/lua/scratch_comments/model/views.lua +++ b/lua/scratch_comments/model/views.lua @@ -46,13 +46,20 @@ end function M.anchored() local views = {} for _, comment in ipairs(store.all()) do - local start_line, end_line, start_col, end_col = anchors.range(comment) + local start_line, end_line, start_col, end_col + if comment.state == "parked" then + local where = comment.last_location + start_line, end_line = where.start_line, where.end_line + start_col, end_col = where.start_col, where.end_col + elseif comment.state ~= "inactive" then + start_line, end_line, start_col, end_col = anchors.range(comment) + end if start_line and end_line then local view = vim.deepcopy(comment) --[[@as ScratchCommentView]] view.start_line, view.end_line = start_line, end_line view.start_col, view.end_col = start_col, end_col - view.snippet = - table.concat(M.text(comment.bufnr, start_line, end_line, start_col, end_col), "\n") + view.snippet = comment.state == "parked" and comment.snippet + or table.concat(M.text(comment.bufnr, start_line, end_line, start_col, end_col), "\n") table.insert(views, view) end end @@ -81,7 +88,9 @@ end ---@return ScratchComment[] function M.orphans() - return vim.tbl_filter(anchors.is_orphaned, store.all()) + return vim.tbl_filter(function(comment) + return comment.state == "inactive" or (comment.state == nil and anchors.is_orphaned(comment)) + end, store.all()) end return M diff --git a/lua/scratch_comments/ui/card.lua b/lua/scratch_comments/ui/card.lua index 2d918e5..7ed300e 100644 --- a/lua/scratch_comments/ui/card.lua +++ b/lua/scratch_comments/ui/card.lua @@ -6,11 +6,12 @@ local M = {} ---@param view ScratchCommentView ---@return ScratchFrame function M.of(view) - local in_file = views.in_buffer(view.bufnr) + local in_file = view.bufnr and views.in_buffer(view.bufnr) or {} return { title = location.title(view.file_path, view), context = vim.split(view.snippet, "\n"), - filetype = vim.bo[view.bufnr].filetype, + filetype = view.bufnr and vim.api.nvim_buf_is_valid(view.bufnr) and vim.bo[view.bufnr].filetype + or "", comment = view.comment, comment_title = ("comment (%d of %d)"):format(views.index_of(in_file, view.id) or 0, #in_file), } diff --git a/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua index c16760d..321dc67 100644 --- a/tests/features/anchoring_spec.lua +++ b/tests/features/anchoring_spec.lua @@ -148,8 +148,80 @@ describe("a file's comments", function() expect.equality(#views.anchored(), 1) end) - it("go with :bd", function() + it("survive :bd and reattach when reopened", function() vim.cmd("bdelete") - expect.equality(#store.all(), 0) + expect.equality(#store.all(), 1) + expect.equality(store.all()[1].state, "parked") + expect.equality(views.anchored()[1].comment, "on the first line") + vim.cmd.edit("tests/fixtures/lifecycle.txt") + vim.wait(0) + expect.equality(store.all()[1].state, nil) + expect.equality(views.anchored()[1].start_line, 1) + end) + + it("keeps a wiped diff-style buffer until the same URI returns", function() + local url = "codediff:////tmp/repo///HEAD/review.lua" + vim.cmd.enew() + vim.api.nvim_buf_set_name(0, url) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "one", "two" }) + vim.bo.bufhidden = "wipe" + vim.cmd("2Comment") + helpers.write("virtual note") + local old = vim.api.nvim_get_current_buf() + vim.cmd("enew!") + expect.equality(vim.api.nvim_buf_is_valid(old), false) + expect.equality(store.all()[#store.all()].state, "parked") + vim.api.nvim_buf_set_name(0, url) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "one", "two" }) + vim.api.nvim_exec_autocmds("BufEnter", { buffer = 0 }) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, nil) + expect.equality(store.all()[#store.all()].comment, "virtual note") + expect.equality(#views.anchored(), 2) + end) + + it("waits for a delayed URI read before reattaching", function() + local url = "review-test:///later.lua" + local pending + vim.api.nvim_create_autocmd("BufReadCmd", { + pattern = "review-test:///*", + once = true, + callback = function(args) + pending = args.buf + end, + }) + vim.cmd.enew() + vim.api.nvim_buf_set_name(0, url) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "one", "two" }) + vim.bo.bufhidden = "wipe" + vim.cmd("2Comment") + helpers.write("delayed") + vim.cmd("enew!") + expect.equality(store.all()[#store.all()].state, "parked") + vim.cmd.edit(url) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, "parked") + expect.equality(pending, vim.api.nvim_get_current_buf()) + vim.api.nvim_buf_set_lines(pending, 0, -1, false, { "one", "two" }) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, nil) + expect.equality(views.in_buffer(pending)[1].start_line, 2) + end) + + it("marks a changed saved range inactive after reopening", function() + vim.cmd("bdelete") + vim.cmd.edit("tests/fixtures/lifecycle.txt") + vim.api.nvim_buf_set_lines(0, 0, 1, false, { "different" }) + vim.wait(0) + expect.equality(#views.orphans(), 1) + expect.equality(#views.anchored(), 0) + end) + + it("retains an orphan when its buffer is wiped", function() + vim.cmd("1d") + helpers.text_changed() + expect.equality(#views.orphans(), 1) + vim.cmd("bwipeout!") + expect.equality(#views.orphans(), 1) end) end) -- 2.54.0 From f5837f05bfb44d72228fd6996076e80c3ebd9e39 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 29 Sep 2026 16:03:40 -0700 Subject: [PATCH 02/13] Manage comments by ID from the quickfix list --- lua/scratch_comments/list.lua | 161 +++++++++++++++++++++++++-- lua/scratch_comments/ui/quickfix.lua | 13 ++- tests/features/list_export_spec.lua | 104 +++++++++++++++++ 3 files changed, 266 insertions(+), 12 deletions(-) diff --git a/lua/scratch_comments/list.lua b/lua/scratch_comments/list.lua index 7810b8f..26bc017 100644 --- a/lua/scratch_comments/list.lua +++ b/lua/scratch_comments/list.lua @@ -1,4 +1,6 @@ local card = require("scratch_comments.ui.card") +local comments = require("scratch_comments.comments") +local store = require("scratch_comments.model.store") local preview = require("scratch_comments.ui.preview") local notify = require("scratch_comments.ui.notify") local quickfix = require("scratch_comments.ui.quickfix") @@ -7,6 +9,10 @@ local views = require("scratch_comments.model.views") local M = {} +local list_id +local list_bufnr +local active_filter + ---@param comment ScratchComment|ScratchCommentView ---@return string local function haystack(comment) @@ -15,12 +21,12 @@ end -- Keeps the comments that fuzzy match, in the order they were listed. ---@generic T: ScratchComment ----@param comments T[] +---@param entries T[] ---@param filter string ---@return T[] -local function matching(comments, filter) +local function matching(entries, filter) local candidates = {} - for index, comment in ipairs(comments) do + for index, comment in ipairs(entries) do table.insert(candidates, { index = index, text = haystack(comment) }) end local kept = {} @@ -29,7 +35,7 @@ local function matching(comments, filter) end local matched = {} - for index, comment in ipairs(comments) do + for index, comment in ipairs(entries) do if kept[index] then table.insert(matched, comment) end @@ -37,6 +43,132 @@ local function matching(comments, filter) return matched end +---@param id string +---@return ScratchComment? +local function by_id(id) + return vim.iter(store.all()):find(function(comment) + return comment.id == id + end) +end + +---@return integer?, string? +local function selected() + if vim.api.nvim_get_current_buf() ~= list_bufnr then + return nil + end + local qf = vim.fn.getqflist({ id = 0, items = 1 }) + if qf.id ~= list_id then + return nil + end + local index = vim.api.nvim_win_get_cursor(0)[1] + local data = qf.items[index] and qf.items[index].user_data + return index, data and data.scratch_comments_id +end + +local function jump() + local index, id = selected() + if not index then + -- Another plugin replaced our quickfix list; leave its normal navigation usable. + vim.cmd.cc(vim.api.nvim_win_get_cursor(0)[1]) + return + end + if not id then + notify.warn("This comment is gone") + return + end + local comment = by_id(id) + if not comment then + notify.warn("This comment is gone") + return + end + if + comment.state == "inactive" + or ( + comment.state == nil + and #vim.tbl_filter(function(c) + return c.id == id + end, views.orphans()) > 0 + ) + then + notify.warn("This comment no longer has a location") + return + end + local ok = pcall(function() + vim.cmd(index .. "cc") + end) + if not ok then + notify.warn("Could not open the comment source") + return + end + local bufnr, win = vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() + if vim.api.nvim_buf_get_name(bufnr) ~= comment.source_name then + return + end + local function position() + if not vim.api.nvim_win_is_valid(win) or vim.api.nvim_win_get_buf(win) ~= bufnr then + return true + end + local current = by_id(id) + if not current then + return true + end + local view = vim.iter(views.in_buffer(bufnr)):find(function(v) + return v.id == id + end) + if view then + vim.api.nvim_win_set_cursor(win, { view.start_line, view.start_col or 0 }) + return true + end + if current.state == "inactive" then + notify.warn("This comment no longer has a location") + return true + end + return false + end + if position() then + return + end + -- An async BufReadCmd may fill the buffer after :cc has already moved the + -- cursor. Wait for its content change, then use the reattached location. + vim.api.nvim_buf_attach(bufnr, false, { + on_lines = function() + vim.schedule(position) + end, + }) +end + +local function delete_selected() + local index, id = selected() + if not index then + notify.warn("Not a Scratch Comments list") + return + end + if not id then + notify.warn("This comment is gone") + return + end + local comment = by_id(id) + if not comment then + notify.warn("This comment is gone") + return + end + comments.delete(comment) + local anchored, orphans = views.anchored(), views.orphans() + if active_filter and active_filter ~= "" then + anchored, orphans = matching(anchored, active_filter), matching(orphans, active_filter) + end + if #anchored + #orphans == 0 then + preview.close() + vim.fn.setqflist({}, "r", { items = {} }) + vim.cmd.cclose() + notify.info("No comments left in the list") + return + end + M.open(active_filter) + vim.api.nvim_win_set_cursor(0, { math.min(index, #anchored + #orphans), 0 }) + vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) +end + -- Every comment in the quickfix list, with the one under the cursor shown -- in a card above it. ---@param filter? string only comments matching this @@ -45,13 +177,22 @@ function M.open(filter) notify.warn("Save or discard the comment first") return end + active_filter = filter local anchored, orphans = views.anchored(), views.orphans() if filter and filter ~= "" then anchored, orphans = matching(anchored, filter), matching(orphans, filter) end - local listed = vim.list_extend(vim.list_slice(anchored), orphans) - quickfix.list(anchored, orphans, function(index) - local entry = index and listed[index] + local listed = {} + for _, entry in ipairs(anchored) do + listed[entry.id] = entry + end + for _, entry in ipairs(orphans) do + listed[entry.id] = entry + end + list_id = quickfix.list(anchored, orphans, function(index) + local qf = vim.fn.getqflist({ items = 1 }) + local data = index and qf.items[index] and qf.items[index].user_data + local entry = data and listed[data.scratch_comments_id] if not entry then preview.close() elseif entry.start_line then @@ -60,6 +201,12 @@ function M.open(filter) preview.show(card.of_orphan(entry)) end end) + if not list_id then + return + end + list_bufnr = vim.api.nvim_get_current_buf() + vim.keymap.set("n", "", jump, { buffer = list_bufnr, desc = "Jump to comment" }) + vim.keymap.set("n", "d", delete_selected, { buffer = list_bufnr, desc = "Delete comment" }) end return M diff --git a/lua/scratch_comments/ui/quickfix.lua b/lua/scratch_comments/ui/quickfix.lua index c6070a9..024796d 100644 --- a/lua/scratch_comments/ui/quickfix.lua +++ b/lua/scratch_comments/ui/quickfix.lua @@ -56,7 +56,8 @@ function M.list(views, orphans, on_entry) local entries = vim.tbl_map(function(comment) return { - filename = comment.file_path, + filename = comment.source_name, + user_data = { scratch_comments_id = comment.id }, lnum = comment.start_line, end_lnum = comment.end_line, col = comment.start_col and comment.start_col + 1, @@ -65,15 +66,17 @@ function M.list(views, orphans, on_entry) } end, views) for _, comment in ipairs(orphans) do - table.insert( - entries, - { filename = comment.file_path, text = "[orphaned] " .. summary(comment.comment) } - ) + table.insert(entries, { + filename = comment.source_name, + user_data = { scratch_comments_id = comment.id }, + text = "[orphaned] " .. summary(comment.comment), + }) end vim.fn.setqflist({}, " ", { title = "Comments", items = entries }) vim.cmd.copen() follow(on_entry) + return vim.fn.getqflist({ id = 0 }).id end return M diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index 319ade6..491ead7 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -1,6 +1,7 @@ local clipboard = require("scratch_comments.export.clipboard") local helpers = require("helpers") local scratch = require("scratch_comments") +local store = require("scratch_comments.model.store") local expect = MiniTest.expect local copy = clipboard.copy @@ -103,6 +104,109 @@ describe(":CommentList", function() end) end) +describe("the comment list manager", function() + it("deletes the selected comment and keeps the filtered list", function() + vim.cmd("CommentList export-fixture") + local removed = vim.fn.getqflist()[1].user_data.scratch_comments_id + vim.cmd("normal d") + expect.equality(#store.all(), 1) + expect.no_equality(store.all()[1].id, removed) + expect.equality(#vim.fn.getqflist(), 1) + expect.equality(vim.bo.buftype, "quickfix") + vim.cmd.cclose() + end) + + it("deletes a parked comment without opening its source", function() + vim.cmd("bwipeout! " .. source) + expect.equality(#store.all(), 2) + vim.cmd.CommentList() + vim.cmd("normal d") + expect.equality(#store.all(), 1) + expect.equality(store.all()[1].comment, "on c") + vim.cmd.cclose() + end) + + it("deletes an inactive comment from the list", function() + vim.cmd.cclose() + vim.api.nvim_set_current_buf(source) + vim.cmd("1d") + helpers.text_changed() + vim.cmd.CommentList() + local qf = vim.fn.getqflist() + local index = vim.iter(qf):enumerate():find(function(_, item) + return item.text:find("[orphaned]", 1, true) ~= nil + end) + vim.api.nvim_win_set_cursor(0, { index, 0 }) + vim.cmd("normal d") + expect.equality(#store.all(), 1) + expect.equality(store.all()[1].comment, "on c") + vim.cmd.cclose() + end) + + it("does not delete another plugin's quickfix entry", function() + vim.cmd.CommentList() + vim.fn.setqflist({}, " ", { items = { { filename = "other.lua", lnum = 1, text = "other" } } }) + vim.cmd("normal d") + expect.equality(#store.all(), 2) + vim.cmd.cclose() + end) + + it("jumps to a parked URI after a delayed read", function() + local url = "review-test:///comments.lua" + local pending + vim.api.nvim_create_autocmd("BufReadCmd", { + pattern = "review-test:///*", + once = true, + callback = function(args) + pending = args.buf + end, + }) + vim.cmd("enew!") + vim.api.nvim_buf_set_name(0, url) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "first", "second" }) + vim.bo.bufhidden = "wipe" + vim.cmd("2Comment") + helpers.write("URI comment") + local id = store.all()[#store.all()].id + vim.cmd("enew!") + vim.cmd.CommentList() + local index + for i, item in ipairs(vim.fn.getqflist()) do + if item.user_data.scratch_comments_id == id then + index = i + end + end + vim.api.nvim_win_set_cursor(0, { index, 0 }) + vim.cmd("normal \r") + expect.equality(pending, vim.api.nvim_get_current_buf()) + expect.equality(store.all()[#store.all()].state, "parked") + vim.api.nvim_buf_set_lines(pending, 0, -1, false, { "first", "second" }) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, nil) + expect.equality(vim.api.nvim_win_get_cursor(0)[1], 2) + end) + + it("jumps to a parked file and restores its comment", function() + vim.cmd("edit! tests/fixtures/lifecycle.txt") + vim.cmd.Comment() + helpers.write("in a real file") + local wanted = store.all()[#store.all()].id + vim.cmd("bdelete") + vim.cmd.CommentList() + local index + for i, item in ipairs(vim.fn.getqflist()) do + if item.user_data.scratch_comments_id == wanted then + index = i + end + end + vim.api.nvim_win_set_cursor(0, { index, 0 }) + vim.cmd("normal \r") + vim.wait(0) + expect.equality(vim.api.nvim_buf_get_name(0), store.all()[#store.all()].source_name) + expect.equality(store.all()[#store.all()].state, nil) + end) +end) + describe("markdown", function() it("names a range's lines", function() vim.cmd("1,2Comment") -- 2.54.0 From fd01540817297d98f3b062bedcb19188b3cfcef5 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 29 Sep 2026 16:13:42 -0700 Subject: [PATCH 03/13] Harden async reattachment and document session retention --- README.md | 35 ++++++++++++++++++++------- doc/scratch-comments.txt | 37 +++++++++++++++++++---------- lua/scratch_comments/actions.lua | 3 ++- lua/scratch_comments/comments.lua | 33 +++++++++++++++++++++---- lua/scratch_comments/list.lua | 10 +++++++- lua/scratch_comments/ui/card.lua | 4 +++- tests/features/anchoring_spec.lua | 28 +++++++++++++++++++++- tests/features/list_export_spec.lua | 14 +++++++++++ 8 files changed, 134 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 0d4a871..7530881 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ down a range. to read the comment on the cursor line: :CommentShow ``` -browse every comment, then copy them all: +browse and manage every comment, then copy them all: ```vim :CommentList @@ -115,8 +115,12 @@ comment in that window instead. save or discard your changes first. `:CommentList` puts all your comments in the quickfix list, so `]q` and `[q` move between them across files. while the list is open, the comment under the -cursor is shown in a card above it. give it an argument to list only the -comments that fuzzy match it, in their text, snippet or path: +cursor is shown in a card above it. press `` on an entry to open its source +and jump to the comment (even if that buffer was closed), or `d` to delete it +without opening its source. these keys belong to the built-in quickfix list; +other quickfix viewers can still display the entries. give the command an +argument to list only comments that fuzzy match it, in their text, snippet or +path: ```vim :CommentList typo @@ -133,16 +137,29 @@ any quickfix viewer works: ## retention -comments go away when you close the buffer (`:bd`, `:bw`) or run -`:CommentClear`. to keep them, export them to a file. +comments remain in memory when you close, unload or wipe a buffer (`:bd`, +`:bunload`, `:bw`). the list and export still include them, using their last +known location and snippet. reopening the same source restores signs if its +saved text still occupies the same range; comments do not jump to a different +location automatically. comments go away when you delete them, run +`:CommentClear`, or exit neovim. export to a file if you need to keep them +after a restart. + +for sources that load asynchronously (such as git revision buffers), a comment +can remain active without a sign while the source loads. if the plugin cannot +confirm the load is complete, it leaves the comment active rather than +mistakenly calling it an orphan. the signs share the sign column with plugins like gitsigns, and can cover their signs. `:CommentToggle` hides ours when you need to see theirs. -if you delete all the lines a comment is on, the comment becomes an orphan. -orphans are listed last in `:CommentList` and in an "Orphaned" section of the -export, and undo restores them. to delete one, run `:CommentDelete` on a line -with no comment. +if you delete all the lines a comment is on, the comment becomes inactive +(an orphan). a completed ordinary file reload whose saved text no longer +matches also makes it inactive. inactive comments are listed last in +`:CommentList` and in an "Orphaned" section of the export. undo restores a +comment whose original extmark is still present. delete inactive comments +with `d` in the list, or run `:CommentDelete` on a line with no comment in +the same buffer. ## colors diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index 868fd86..18e8a2c 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -17,8 +17,8 @@ Comment on any lines in any file, then copy the comments out as Markdown or JSON to paste into an LLM or give to a person. Commented lines get a sign, and |:CommentShow| shows the comment on the cursor line. -Comments live in the buffer. They go away when you close the buffer or clear -them. +Comments live in memory for the Neovim session. Closing a buffer does not +delete its comments; |:CommentClear| or exiting Neovim does. The commands work as soon as the plugin is installed. There is no setup. Add your own mappings. See |scratch-comments-api| for examples. @@ -82,10 +82,13 @@ Requires Neovim 0.12 or newer. While the list is open, the comment the cursor is on is shown in a card above it, with the lines it is on. - The card goes away with the list. - Any quickfix front-end (trouble.nvim, nvim-bqf, - Snacks.picker.qflist, :Telescope quickfix) can show - it. + Press to open its source and jump to it (including + a source whose buffer was closed), or d to delete that + comment without opening the source. The card goes away + with the list. Other quickfix front-ends (trouble.nvim, + nvim-bqf, Snacks.picker.qflist, :Telescope quickfix) + can show the entries; the and d mappings belong + to the built-in list. *:CommentExport* :CommentExport[!] [format] @@ -116,14 +119,22 @@ selected within them. ============================================================================== 4. RETENTION *scratch-comments-retention* -Comments go away when the buffer is deleted (|:bdelete|, |:bwipeout|) or when -you run |:CommentClear|. Exporting leaves them in place, so you can export as -often as you like. To keep comments, save an export to a file. +Closing, unloading or wiping a buffer does not delete its comments. They +remain in memory with their last location and snippet until you delete them, +run |:CommentClear|, or exit Neovim. Exporting leaves them in place. Save an +export to a file if you need to keep comments after exiting Neovim. -If you delete all the lines a comment is on, the comment becomes an orphan. -Orphans are listed last in |:CommentList| and in an "Orphaned" section of the -export, and |undo| restores them. To delete one, run |:CommentDelete| on a -line with no comment. +When the same source is reopened, signs return if the saved text still occupies +the same range. Comments are not automatically moved to other lines. For +asynchronously loaded sources, a comment may remain active without a sign +until its text arrives; an incomplete load is not treated as an orphan. + +If you delete all the lines a comment is on, the comment becomes inactive (an +orphan). A completed ordinary file reload with mismatched text also makes a +parked comment inactive. Inactive comments are listed last in |:CommentList| +and in an "Orphaned" section of the export; |undo| restores an extmark whose +buffer is still present. Delete an inactive comment with d in the list, or +use |:CommentDelete| on a line with no comment in the same buffer. In tmux you may need to set the clipboard to OSC 52 yourself: >lua vim.g.clipboard = "osc52" diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua index 59135a3..1175692 100644 --- a/lua/scratch_comments/actions.lua +++ b/lua/scratch_comments/actions.lua @@ -188,8 +188,9 @@ function M.delete_current() end local bufnr = vim.api.nvim_get_current_buf() + local name = vim.api.nvim_buf_get_name(bufnr) local orphans = vim.tbl_filter(function(comment) - return comment.bufnr == bufnr + return comment.bufnr == bufnr or (comment.state == "inactive" and comment.source_name == name) end, views.orphans()) if #orphans == 0 then notify.info("No comment at cursor") diff --git a/lua/scratch_comments/comments.lua b/lua/scratch_comments/comments.lua index 4464442..4823f9a 100644 --- a/lua/scratch_comments/comments.lua +++ b/lua/scratch_comments/comments.lua @@ -105,6 +105,7 @@ function M.park_buffer(bufnr) else comment.state = "inactive" end + anchors.clear_all({ comment }) comment.bufnr, comment.extmark_id = nil, nil end end @@ -112,7 +113,8 @@ end -- An active source may reopen under a different bufnr. Its old line number is -- only trusted when the saved text still occupies that exact range. ---@param bufnr integer -function M.reattach(bufnr) +---@param definitive? boolean true after a completed ordinary file read +function M.reattach(bufnr, definitive) if not vim.api.nvim_buf_is_loaded(bufnr) then return end @@ -127,6 +129,14 @@ function M.reattach(bufnr) end local changed = false for _, comment in ipairs(store.all()) do + -- An async provider can replace a partial, already-matching range. That + -- does not prove the source lines were deleted; retry after the update. + if name:match("^%w[%w+.-]*://") and comment.bufnr == bufnr and anchors.is_orphaned(comment) then + anchors.clear_all({ comment }) + comment.bufnr, comment.extmark_id = nil, nil + comment.state = "parked" + changed = true + end if comment.source_name == name and comment.state == "parked" then local where = comment.last_location local count = vim.api.nvim_buf_line_count(bufnr) @@ -138,10 +148,10 @@ function M.reattach(bufnr) anchors.anchor(comment, where.start_line, where.end_line, where.start_col, where.end_col) comment.state = nil changed = true - else + elseif definitive then comment.state = "inactive" end - else + elseif definitive then comment.state = "inactive" end end @@ -180,7 +190,7 @@ vim.api.nvim_create_autocmd("BufReadPost", { callback = function(args) vim.schedule(function() if vim.api.nvim_buf_is_valid(args.buf) then - M.reattach(args.buf) + M.reattach(args.buf, true) end end) end, @@ -191,6 +201,13 @@ vim.api.nvim_create_autocmd("BufEnter", { callback = function(args) local bufnr = args.buf if vim.api.nvim_buf_is_loaded(bufnr) then + local name = vim.api.nvim_buf_get_name(bufnr) + local has_parked = vim.iter(store.all()):any(function(comment) + return comment.source_name == name and comment.state == "parked" + end) + if not has_parked then + return + end if not watched[bufnr] then watched[bufnr] = vim.api.nvim_buf_attach(bufnr, false, { on_lines = function() @@ -199,6 +216,14 @@ vim.api.nvim_create_autocmd("BufEnter", { M.reattach(bufnr) end end) + if + not name:match("^%w[%w+.-]*://") + and not vim.iter(store.all()):any(function(comment) + return comment.source_name == name and comment.state == "parked" + end) + then + return true + end end, on_detach = function() watched[bufnr] = nil diff --git a/lua/scratch_comments/list.lua b/lua/scratch_comments/list.lua index 26bc017..5f26b06 100644 --- a/lua/scratch_comments/list.lua +++ b/lua/scratch_comments/list.lua @@ -130,9 +130,17 @@ local function jump() end -- An async BufReadCmd may fill the buffer after :cc has already moved the -- cursor. Wait for its content change, then use the reattached location. + local done = false vim.api.nvim_buf_attach(bufnr, false, { on_lines = function() - vim.schedule(position) + if done then + return true + end + vim.schedule(function() + if not done and position() then + done = true + end + end) end, }) end diff --git a/lua/scratch_comments/ui/card.lua b/lua/scratch_comments/ui/card.lua index 7ed300e..33a6d65 100644 --- a/lua/scratch_comments/ui/card.lua +++ b/lua/scratch_comments/ui/card.lua @@ -6,7 +6,9 @@ local M = {} ---@param view ScratchCommentView ---@return ScratchFrame function M.of(view) - local in_file = view.bufnr and views.in_buffer(view.bufnr) or {} + local in_file = vim.tbl_filter(function(candidate) + return candidate.source_name == view.source_name + end, views.anchored()) return { title = location.title(view.file_path, view), context = vim.split(view.snippet, "\n"), diff --git a/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua index 321dc67..1d5d5a6 100644 --- a/tests/features/anchoring_spec.lua +++ b/tests/features/anchoring_spec.lua @@ -159,6 +159,19 @@ describe("a file's comments", function() expect.equality(views.anchored()[1].start_line, 1) end) + it("retains a comment through :bunload and :bwipeout", function() + local source = vim.api.nvim_get_current_buf() + vim.cmd.enew() + vim.cmd("bunload tests/fixtures/lifecycle.txt") + expect.equality(store.all()[1].state, "parked") + local ns = vim.api.nvim_get_namespaces().scratch_comments + expect.equality(#vim.api.nvim_buf_get_extmarks(source, ns, 0, -1, {}), 0) + expect.equality(#views.anchored(), 1) + vim.cmd("bwipeout! tests/fixtures/lifecycle.txt") + expect.equality(#store.all(), 1) + expect.equality(store.all()[1].state, "parked") + end) + it("keeps a wiped diff-style buffer until the same URI returns", function() local url = "codediff:////tmp/repo///HEAD/review.lua" vim.cmd.enew() @@ -176,6 +189,7 @@ describe("a file's comments", function() vim.api.nvim_exec_autocmds("BufEnter", { buffer = 0 }) vim.wait(0) expect.equality(store.all()[#store.all()].state, nil) + expect.equality(helpers.sign_at(2), "│") expect.equality(store.all()[#store.all()].comment, "virtual note") expect.equality(#views.anchored(), 2) end) @@ -202,7 +216,15 @@ describe("a file's comments", function() vim.wait(0) expect.equality(store.all()[#store.all()].state, "parked") expect.equality(pending, vim.api.nvim_get_current_buf()) - vim.api.nvim_buf_set_lines(pending, 0, -1, false, { "one", "two" }) + vim.api.nvim_buf_set_lines(pending, 0, -1, false, { "one" }) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, "parked") + vim.api.nvim_buf_set_lines(pending, 1, -1, false, { "two" }) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, nil) + expect.equality(views.in_buffer(pending)[1].start_line, 2) + -- A provider may replace an already-matching partial result again. + vim.api.nvim_buf_set_lines(pending, 1, 2, false, { "two" }) vim.wait(0) expect.equality(store.all()[#store.all()].state, nil) expect.equality(views.in_buffer(pending)[1].start_line, 2) @@ -215,6 +237,10 @@ describe("a file's comments", function() vim.wait(0) expect.equality(#views.orphans(), 1) expect.equality(#views.anchored(), 0) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + helpers.pick(1) + vim.cmd.CommentDelete() + expect.equality(#store.all(), 0) end) it("retains an orphan when its buffer is wiped", function() diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index 491ead7..28820e3 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -116,6 +116,16 @@ describe("the comment list manager", function() vim.cmd.cclose() end) + it("lists and exports comments after their source was wiped", function() + vim.cmd("bwipeout! " .. source) + local data = vim.json.decode(scratch.render("json")) + expect.equality(#data.comments, 2) + expect.equality(data.comments[1].snippet, "local a = 1") + vim.cmd.CommentList() + expect.equality(#vim.fn.getqflist(), 2) + vim.cmd.cclose() + end) + it("deletes a parked comment without opening its source", function() vim.cmd("bwipeout! " .. source) expect.equality(#store.all(), 2) @@ -184,6 +194,10 @@ describe("the comment list manager", function() vim.wait(0) expect.equality(store.all()[#store.all()].state, nil) expect.equality(vim.api.nvim_win_get_cursor(0)[1], 2) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.api.nvim_buf_set_lines(pending, 0, 1, false, { "updated" }) + vim.wait(0) + expect.equality(vim.api.nvim_win_get_cursor(0)[1], 1) end) it("jumps to a parked file and restores its comment", function() -- 2.54.0 From f3189aece94e2132b021c4225671ae492d3f1cbf Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 29 Sep 2026 16:16:38 -0700 Subject: [PATCH 04/13] Bound pending quickfix jumps for asynchronous sources --- lua/scratch_comments/list.lua | 36 +++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/lua/scratch_comments/list.lua b/lua/scratch_comments/list.lua index 5f26b06..4f05eec 100644 --- a/lua/scratch_comments/list.lua +++ b/lua/scratch_comments/list.lua @@ -12,6 +12,8 @@ local M = {} local list_id local list_bufnr local active_filter +local pending_jump = {} +local listening = {} ---@param comment ScratchComment|ScratchCommentView ---@return string @@ -66,6 +68,10 @@ local function selected() end local function jump() + -- Only the most recent manager jump may move the cursor after a delayed read. + for bufnr in pairs(pending_jump) do + pending_jump[bufnr] = nil + end local index, id = selected() if not index then -- Another plugin replaced our quickfix list; leave its normal navigation usable. @@ -129,19 +135,37 @@ local function jump() return end -- An async BufReadCmd may fill the buffer after :cc has already moved the - -- cursor. Wait for its content change, then use the reattached location. - local done = false - vim.api.nvim_buf_attach(bufnr, false, { + -- cursor. Keep one listener per buffer even if the user jumps repeatedly. + pending_jump[bufnr] = position + vim.api.nvim_create_autocmd("BufLeave", { + buffer = bufnr, + once = true, + callback = function() + if pending_jump[bufnr] == position then + pending_jump[bufnr] = nil + end + end, + }) + if listening[bufnr] then + return + end + listening[bufnr] = vim.api.nvim_buf_attach(bufnr, false, { on_lines = function() - if done then + local current = pending_jump[bufnr] + if not current then + listening[bufnr] = nil return true end vim.schedule(function() - if not done and position() then - done = true + if pending_jump[bufnr] == current and current() then + pending_jump[bufnr] = nil end end) end, + on_detach = function() + pending_jump[bufnr] = nil + listening[bufnr] = nil + end, }) end -- 2.54.0 From 4214ab0167dcadff0d94e8db35653f3fdc1e0152 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 29 Sep 2026 16:32:46 -0700 Subject: [PATCH 05/13] Centralize URI detection and align buffer lifecycle names --- lua/scratch_comments/actions.lua | 4 ++-- lua/scratch_comments/comments.lua | 18 ++++++++++-------- lua/scratch_comments/paths.lua | 8 ++++++++ tests/unit/paths_spec.lua | 9 +++++++++ 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua index 1175692..c3a5420 100644 --- a/lua/scratch_comments/actions.lua +++ b/lua/scratch_comments/actions.lua @@ -76,7 +76,7 @@ end ---@param file_path string ---@param where ScratchLocation local function edit_new(bufnr, file_path, where) - local relative_path = file_path:match("^%w[%w+.-]*://") and file_path + local relative_path = paths.is_uri(file_path) and file_path or paths.relative_path(paths.git_root(file_path), file_path) ---@type ScratchComment? local added @@ -139,7 +139,7 @@ function M.comment(start_line, end_line, use_selection) return end - edit_new(bufnr, name:match("^%w[%w+.-]*://") and name or vim.fn.fnamemodify(name, ":p"), where) + edit_new(bufnr, paths.is_uri(name) and name or vim.fn.fnamemodify(name, ":p"), where) end ---@param view ScratchCommentView diff --git a/lua/scratch_comments/comments.lua b/lua/scratch_comments/comments.lua index 4823f9a..053ad91 100644 --- a/lua/scratch_comments/comments.lua +++ b/lua/scratch_comments/comments.lua @@ -1,4 +1,5 @@ local anchors = require("scratch_comments.model.anchors") +local paths = require("scratch_comments.paths") local signs = require("scratch_comments.ui.signs") local store = require("scratch_comments.model.store") local views = require("scratch_comments.model.views") @@ -114,14 +115,15 @@ end -- only trusted when the saved text still occupies that exact range. ---@param bufnr integer ---@param definitive? boolean true after a completed ordinary file read -function M.reattach(bufnr, definitive) +function M.attach_buffer(bufnr, definitive) if not vim.api.nvim_buf_is_loaded(bufnr) then return end local name = vim.api.nvim_buf_get_name(bufnr) + local is_uri = paths.is_uri(name) -- BufReadCmd providers can return from the command before asynchronously -- supplying any text. The initial empty line is not a completed read. - if name:match("^%w[%w+.-]*://") then + if is_uri then local first = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] if first == "" and vim.api.nvim_buf_line_count(bufnr) == 1 then return @@ -131,7 +133,7 @@ function M.reattach(bufnr, definitive) for _, comment in ipairs(store.all()) do -- An async provider can replace a partial, already-matching range. That -- does not prove the source lines were deleted; retry after the update. - if name:match("^%w[%w+.-]*://") and comment.bufnr == bufnr and anchors.is_orphaned(comment) then + if is_uri and comment.bufnr == bufnr and anchors.is_orphaned(comment) then anchors.clear_all({ comment }) comment.bufnr, comment.extmark_id = nil, nil comment.state = "parked" @@ -190,7 +192,7 @@ vim.api.nvim_create_autocmd("BufReadPost", { callback = function(args) vim.schedule(function() if vim.api.nvim_buf_is_valid(args.buf) then - M.reattach(args.buf, true) + M.attach_buffer(args.buf, true) end end) end, @@ -213,11 +215,11 @@ vim.api.nvim_create_autocmd("BufEnter", { on_lines = function() vim.schedule(function() if vim.api.nvim_buf_is_valid(bufnr) then - M.reattach(bufnr) + M.attach_buffer(bufnr) end end) if - not name:match("^%w[%w+.-]*://") + not paths.is_uri(name) and not vim.iter(store.all()):any(function(comment) return comment.source_name == name and comment.state == "parked" end) @@ -233,10 +235,10 @@ vim.api.nvim_create_autocmd("BufEnter", { -- A previously loaded file can be reentered without a read event. For -- URI buffers an initial empty pane may still be waiting for BufReadCmd. local lines = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false) - if not vim.api.nvim_buf_get_name(bufnr):match("^%w[%w+.-]*://") or lines[1] ~= "" then + if not paths.is_uri(name) or lines[1] ~= "" then vim.schedule(function() if vim.api.nvim_buf_is_valid(bufnr) then - M.reattach(bufnr) + M.attach_buffer(bufnr) end end) end diff --git a/lua/scratch_comments/paths.lua b/lua/scratch_comments/paths.lua index f36f438..7aa82c8 100644 --- a/lua/scratch_comments/paths.lua +++ b/lua/scratch_comments/paths.lua @@ -1,5 +1,13 @@ local M = {} +-- Buffer names may be virtual URIs rather than filesystem paths. Recognize +-- the URI scheme, not any particular provider's prefix. +---@param name string +---@return boolean +function M.is_uri(name) + return name:match("^%w[%w+.-]*://") ~= nil +end + local function run(args) local result = vim.system(args, { text = true }):wait() if result.code == 0 then diff --git a/tests/unit/paths_spec.lua b/tests/unit/paths_spec.lua index 89c66ef..6c3ef03 100644 --- a/tests/unit/paths_spec.lua +++ b/tests/unit/paths_spec.lua @@ -1,6 +1,15 @@ local paths = require("scratch_comments.paths") local expect = MiniTest.expect +describe("is_uri", function() + it("recognizes provider-neutral URI buffer names", function() + expect.equality(paths.is_uri("codediff:////repo///HEAD/a.lua"), true) + expect.equality(paths.is_uri("review+git://repo/file.lua"), true) + expect.equality(paths.is_uri("/repo/file.lua"), false) + expect.equality(paths.is_uri("file:with-colon.lua"), false) + end) +end) + describe("git_root", function() it("finds the repository a file is in", function() local root = vim.fn.getcwd() -- 2.54.0 From b4877b79cfce0ef9d233b60b10bdbe3f1feafc5b Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 29 Sep 2026 16:42:38 -0700 Subject: [PATCH 06/13] Tighten README wording for session retention --- README.md | 38 ++++++++++++-------------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 7530881..b1857b1 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ down a range. to read the comment on the cursor line: :CommentShow ``` -browse and manage every comment, then copy them all: +browse every comment, then copy them all: ```vim :CommentList @@ -115,12 +115,9 @@ comment in that window instead. save or discard your changes first. `:CommentList` puts all your comments in the quickfix list, so `]q` and `[q` move between them across files. while the list is open, the comment under the -cursor is shown in a card above it. press `` on an entry to open its source -and jump to the comment (even if that buffer was closed), or `d` to delete it -without opening its source. these keys belong to the built-in quickfix list; -other quickfix viewers can still display the entries. give the command an -argument to list only comments that fuzzy match it, in their text, snippet or -path: +cursor is shown in a card above it. in the built-in list, press `` to go +to a comment (even in a closed file), or `d` to delete it. give it an argument +to list only the comments that fuzzy match it, in their text, snippet or path: ```vim :CommentList typo @@ -137,29 +134,18 @@ any quickfix viewer works: ## retention -comments remain in memory when you close, unload or wipe a buffer (`:bd`, -`:bunload`, `:bw`). the list and export still include them, using their last -known location and snippet. reopening the same source restores signs if its -saved text still occupies the same range; comments do not jump to a different -location automatically. comments go away when you delete them, run -`:CommentClear`, or exit neovim. export to a file if you need to keep them -after a restart. - -for sources that load asynchronously (such as git revision buffers), a comment -can remain active without a sign while the source loads. if the plugin cannot -confirm the load is complete, it leaves the comment active rather than -mistakenly calling it an orphan. +comments stay when you close a buffer (`:bd`, `:bw`). open the same source +again and they'll pick up where they left off if the text is still on the same +lines. they go away when you delete them, run `:CommentClear`, or exit neovim. +to keep them longer, export them to a file. the signs share the sign column with plugins like gitsigns, and can cover their signs. `:CommentToggle` hides ours when you need to see theirs. -if you delete all the lines a comment is on, the comment becomes inactive -(an orphan). a completed ordinary file reload whose saved text no longer -matches also makes it inactive. inactive comments are listed last in -`:CommentList` and in an "Orphaned" section of the export. undo restores a -comment whose original extmark is still present. delete inactive comments -with `d` in the list, or run `:CommentDelete` on a line with no comment in -the same buffer. +if the lines a comment is on are deleted, it becomes an orphan. orphans are +listed last in `:CommentList` and in an "Orphaned" section of the export. undo +restores them while the buffer is open. delete one with `d` in the list, or +run `:CommentDelete` on a line with no comment. ## colors -- 2.54.0 From f5f80f8a1fc5a082d8e7025c2da16e4bba19dfb1 Mon Sep 17 00:00:00 2001 From: eleith Date: Tue, 29 Sep 2026 17:24:59 -0700 Subject: [PATCH 07/13] Edit comments from the manager and delete by saving empty --- README.md | 15 ++-- doc/scratch-comments.txt | 24 ++---- lua/scratch_comments/actions.lua | 118 ++++++++++++++------------ lua/scratch_comments/init.lua | 1 - lua/scratch_comments/list.lua | 120 ++++++++++++++++++++------- lua/scratch_comments/navigate.lua | 6 ++ lua/scratch_comments/ui/frame.lua | 2 +- lua/scratch_comments/ui/quickfix.lua | 23 +++-- lua/scratch_comments/ui/window.lua | 30 ++++++- plugin/scratch-comments.lua | 4 - tests/features/anchoring_spec.lua | 13 ++- tests/features/commenting_spec.lua | 20 +++++ tests/features/list_export_spec.lua | 76 +++++++++++++++++ tests/features/navigation_spec.lua | 4 +- tests/features/plugin_spec.lua | 4 + tests/features/show_delete_spec.lua | 25 +++--- 16 files changed, 339 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index b1857b1..fa8b62d 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,6 @@ it, pipe it to a command, or edit it: | `:CommentShow` | Show the comment at the cursor, with the lines it's on. Edit it there and `:w` to save | | `:CommentNext` | Go to the next comment in the file | | `:CommentPrev` | Go to the previous comment in the file | -| `:CommentDelete` | Delete the comment at the cursor, or pick an orphaned one | | `:CommentList [filter]` | Put comments in the quickfix list and open it, fuzzy matching `filter` | | `:CommentExport[!] [format]` | Copy all comments to the clipboard as `markdown` (default) or `json`. `!` opens them in a scratch buffer | | `:CommentToggle [on\|off]` | Show or hide the comment signs | @@ -116,8 +115,9 @@ comment in that window instead. save or discard your changes first. `:CommentList` puts all your comments in the quickfix list, so `]q` and `[q` move between them across files. while the list is open, the comment under the cursor is shown in a card above it. in the built-in list, press `` to go -to a comment (even in a closed file), or `d` to delete it. give it an argument -to list only the comments that fuzzy match it, in their text, snippet or path: +to a comment (even in a closed file), `e` to edit it, or `d` to delete it. +give it an argument to list only the comments that fuzzy match it, in their +text, snippet or path: ```vim :CommentList typo @@ -136,16 +136,16 @@ any quickfix viewer works: comments stay when you close a buffer (`:bd`, `:bw`). open the same source again and they'll pick up where they left off if the text is still on the same -lines. they go away when you delete them, run `:CommentClear`, or exit neovim. -to keep them longer, export them to a file. +lines. delete one with `d` in the list, or save it empty from its comment +window. `:CommentClear` deletes them all. to keep them after exiting neovim, +export them to a file. the signs share the sign column with plugins like gitsigns, and can cover their signs. `:CommentToggle` hides ours when you need to see theirs. if the lines a comment is on are deleted, it becomes an orphan. orphans are listed last in `:CommentList` and in an "Orphaned" section of the export. undo -restores them while the buffer is open. delete one with `d` in the list, or -run `:CommentDelete` on a line with no comment. +restores them while the buffer is open. delete one with `d` in the list. ## colors @@ -176,7 +176,6 @@ scratch.add(start_line, end_line) -- default: the cursor line scratch.show() scratch.next() scratch.prev() -scratch.delete() scratch.list() scratch.render(format) -- "markdown" (default) or "json" scratch.export(format, in_buffer) -- copy, or open in a scratch buffer diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index 18e8a2c..c49232d 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -50,7 +50,7 @@ Requires Neovim 0.12 or newer. on, in the same window |:Comment| uses. If more than one comment covers the line, asks which one. It opens in normal mode. Edit the comment and |:w| to save, or - |:q| to close it. + |:q| to close it. Saving it empty deletes the comment. *:CommentNext* :CommentNext Go to the next comment in the current file, wrapping @@ -67,12 +67,6 @@ Requires Neovim 0.12 or newer. wrapping at the start. With a comment open, show the previous comment in that window instead. - *:CommentDelete* -:CommentDelete Delete the comment at the cursor. If more than one - comment covers the line, asks which one. On a line - with no comment, lists the buffer's orphaned comments - to choose from. - *:CommentList* :CommentList [filter] Put every comment in the |quickfix| list and open it. With [filter], only the comments that fuzzy match it @@ -82,13 +76,12 @@ Requires Neovim 0.12 or newer. While the list is open, the comment the cursor is on is shown in a card above it, with the lines it is on. - Press to open its source and jump to it (including - a source whose buffer was closed), or d to delete that - comment without opening the source. The card goes away - with the list. Other quickfix front-ends (trouble.nvim, - nvim-bqf, Snacks.picker.qflist, :Telescope quickfix) - can show the entries; the and d mappings belong - to the built-in list. + Press to jump to the source (even if its buffer + was closed), e to edit in the comment card, or d to + delete the comment. The card goes away with the list. + Other quickfix front-ends (trouble.nvim, nvim-bqf, + Snacks.picker.qflist, :Telescope quickfix) can show + the entries; these keys belong to the built-in list. *:CommentExport* :CommentExport[!] [format] @@ -134,7 +127,7 @@ orphan). A completed ordinary file reload with mismatched text also makes a parked comment inactive. Inactive comments are listed last in |:CommentList| and in an "Orphaned" section of the export; |undo| restores an extmark whose buffer is still present. Delete an inactive comment with d in the list, or -use |:CommentDelete| on a line with no comment in the same buffer. +open its card with e and save an empty comment. In tmux you may need to set the clipboard to OSC 52 yourself: >lua vim.g.clipboard = "osc52" @@ -172,7 +165,6 @@ needs 'termguicolors'. For a different dim, or none: >lua scratch.show() scratch.next() scratch.prev() - scratch.delete() scratch.list() scratch.render(format) -- "markdown" (default) or "json" scratch.export(format, in_buffer) -- copy, or open in a scratch buffer diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua index c3a5420..65110b5 100644 --- a/lua/scratch_comments/actions.lua +++ b/lua/scratch_comments/actions.lua @@ -1,6 +1,7 @@ local comments = require("scratch_comments.comments") local location = require("scratch_comments.location") local paths = require("scratch_comments.paths") +local store = require("scratch_comments.model.store") local views = require("scratch_comments.model.views") local notify = require("scratch_comments.ui.notify") local card = require("scratch_comments.ui.card") @@ -53,21 +54,67 @@ local function cursor_comments() end, views.in_buffer(vim.api.nvim_get_current_buf())) end ----@param view ScratchCommentView +---@param id string +---@param text string +---@return boolean|string? "deleted" when saving removes the comment +local function save_existing(id, text) + local comment = vim.iter(store.all()):find(function(candidate) + return candidate.id == id + end) + if not comment then + notify.warn("This comment is gone; nothing was saved") + return false + end + if text == "" then + comments.delete(comment) + notify.info("Deleted comment") + return "deleted" + end + comments.edit(id, text) + notify.info("Updated comment") +end + +---@param view ScratchComment|ScratchCommentView ---@param source_win integer -function M.show(view, source_win) - window.open( - vim.tbl_extend("error", card.of(view), { - on_save = function(text) - if not comments.edit(view.id, text) then - notify.warn("This comment is gone; nothing was saved") - return false - end - notify.info("Updated comment") - end, - }), - { id = view.id, bufnr = view.bufnr, line = view.start_line, source_win = source_win } - ) +---@param on_close? fun() +---@param on_saved? fun() +---@param from_list? boolean +function M.show(view, source_win, on_close, on_saved, from_list) + local frame + if view.start_line then + frame = card.of(view --[[@as ScratchCommentView]]) + else + frame = card.of_orphan(view) + end + frame.on_save = function(text) + return save_existing(view.id, text) + end + window.open(frame, { + id = view.id, + bufnr = view.bufnr, + source_name = view.source_name, + line = view.start_line or view.last_location.start_line, + source_win = source_win, + on_close = on_close, + on_saved = on_saved, + from_list = from_list, + }) +end + +---@param id string +---@param on_close? fun() +---@param on_saved? fun() +function M.show_by_id(id, on_close, on_saved) + local view = vim.iter(views.anchored()):find(function(entry) + return entry.id == id + end) or vim.iter(views.orphans()):find(function(entry) + return entry.id == id + end) + if view then + M.show(view, vim.api.nvim_get_current_win(), on_close, on_saved, true) + else + notify.warn("This comment is gone") + end end -- Opens an editor for a comment that does not exist yet; it is created when @@ -90,13 +137,9 @@ local function edit_new(bufnr, file_path, where) comment = "", on_save = function(text) if added then - if not comments.edit(added.id, text) then - notify.warn("This comment is gone; nothing was saved") - return false - end - return + return save_existing(added.id, text) end - if not vim.api.nvim_buf_is_valid(bufnr) then + if text == "" or not vim.api.nvim_buf_is_valid(bufnr) then return end added = comments.add({ @@ -162,12 +205,6 @@ local function choose(candidates, callback) end) end ----@param comment ScratchComment -local function delete(comment) - comments.delete(comment) - notify.info("Deleted comment") -end - function M.show_current() local at_cursor = cursor_comments() if #at_cursor == 0 then @@ -180,35 +217,6 @@ function M.show_current() end) end -function M.delete_current() - local at_cursor = cursor_comments() - if #at_cursor > 0 then - choose(at_cursor, delete) - return - end - - local bufnr = vim.api.nvim_get_current_buf() - local name = vim.api.nvim_buf_get_name(bufnr) - local orphans = vim.tbl_filter(function(comment) - return comment.bufnr == bufnr or (comment.state == "inactive" and comment.source_name == name) - end, views.orphans()) - if #orphans == 0 then - notify.info("No comment at cursor") - return - end - - vim.ui.select(orphans, { - prompt = "Delete orphaned comment: ", - format_item = function(comment) - return summary(comment.comment) - end, - }, function(comment) - if comment then - delete(comment) - end - end) -end - ---@param on? boolean function M.toggle(on) if on == nil then diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index e83b50d..0fd3f71 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -13,7 +13,6 @@ function M.add(start_line, end_line) end M.show = actions.show_current -M.delete = actions.delete_current M.toggle = actions.toggle M.clear = actions.clear M.render = export.render diff --git a/lua/scratch_comments/list.lua b/lua/scratch_comments/list.lua index 4f05eec..58846d3 100644 --- a/lua/scratch_comments/list.lua +++ b/lua/scratch_comments/list.lua @@ -1,3 +1,4 @@ +local actions = require("scratch_comments.actions") local card = require("scratch_comments.ui.card") local comments = require("scratch_comments.comments") local store = require("scratch_comments.model.store") @@ -45,6 +46,16 @@ local function matching(entries, filter) return matched end +---@param filter? string +---@return ScratchCommentView[], ScratchComment[] +local function filtered(filter) + local anchored, orphans = views.anchored(), views.orphans() + if filter and filter ~= "" then + anchored, orphans = matching(anchored, filter), matching(orphans, filter) + end + return anchored, orphans +end + ---@param id string ---@return ScratchComment? local function by_id(id) @@ -169,6 +180,56 @@ local function jump() }) end +-- Update the manager behind an open editor, without taking focus or opening +-- a list that the user already closed. Replace items in the same quickfix ID. +---@param id string +---@param index integer +---@param expected_id integer +local function refresh(id, index, expected_id) + local qf = vim.fn.getqflist({ id = 0, winid = 0 }) + if qf.id ~= expected_id or qf.winid == 0 then + return + end + local anchored, orphans = filtered(active_filter) + local items = quickfix.items(anchored, orphans) + vim.fn.setqflist({}, "r", { title = "Comments", items = items }) + if #items == 0 then + preview.close() + vim.api.nvim_win_call(qf.winid, function() + vim.cmd.cclose() + end) + list_id, list_bufnr = nil, nil + return + end + local row = math.min(index, #items) + for i, item in ipairs(items) do + if item.user_data.scratch_comments_id == id then + row = i + break + end + end + vim.api.nvim_win_set_cursor(qf.winid, { row, 0 }) +end + +local function edit_selected() + local index, id = selected() + if not index or not id or not by_id(id) then + notify.warn("This comment is gone") + return + end + local expected_id = list_id + actions.show_by_id(id, function() + vim.schedule(function() + local qf = vim.fn.getqflist({ id = 0, winid = 0 }) + if qf.id == expected_id and qf.winid ~= 0 and vim.api.nvim_win_is_valid(qf.winid) then + vim.api.nvim_set_current_win(qf.winid) + end + end) + end, function() + refresh(id, index, expected_id) + end) +end + local function delete_selected() local index, id = selected() if not index then @@ -185,20 +246,11 @@ local function delete_selected() return end comments.delete(comment) - local anchored, orphans = views.anchored(), views.orphans() - if active_filter and active_filter ~= "" then - anchored, orphans = matching(anchored, active_filter), matching(orphans, active_filter) + local anchored, orphans = filtered(active_filter) + if M.open(active_filter) then + vim.api.nvim_win_set_cursor(0, { math.min(index, #anchored + #orphans), 0 }) + vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) end - if #anchored + #orphans == 0 then - preview.close() - vim.fn.setqflist({}, "r", { items = {} }) - vim.cmd.cclose() - notify.info("No comments left in the list") - return - end - M.open(active_filter) - vim.api.nvim_win_set_cursor(0, { math.min(index, #anchored + #orphans), 0 }) - vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) end -- Every comment in the quickfix list, with the one under the cursor shown @@ -210,27 +262,37 @@ function M.open(filter) return end active_filter = filter - local anchored, orphans = views.anchored(), views.orphans() - if filter and filter ~= "" then - anchored, orphans = matching(anchored, filter), matching(orphans, filter) - end - local listed = {} - for _, entry in ipairs(anchored) do - listed[entry.id] = entry - end - for _, entry in ipairs(orphans) do - listed[entry.id] = entry + local anchored, orphans = filtered(filter) + if #anchored + #orphans == 0 then + if list_id and vim.fn.getqflist({ id = 0 }).id == list_id then + preview.close() + vim.fn.setqflist({}, "r", { items = {} }) + vim.cmd.cclose() + end + list_id, list_bufnr = nil, nil + notify.info("No comments") + return false end list_id = quickfix.list(anchored, orphans, function(index) local qf = vim.fn.getqflist({ items = 1 }) local data = index and qf.items[index] and qf.items[index].user_data - local entry = data and listed[data.scratch_comments_id] - if not entry then - preview.close() - elseif entry.start_line then + local id = data and data.scratch_comments_id + local entry = id + and vim.iter(views.anchored()):find(function(view) + return view.id == id + end) + if entry then preview.show(card.of(entry)) else - preview.show(card.of_orphan(entry)) + local orphan = id + and vim.iter(views.orphans()):find(function(comment) + return comment.id == id + end) + if orphan then + preview.show(card.of_orphan(orphan)) + else + preview.close() + end end end) if not list_id then @@ -239,6 +301,8 @@ function M.open(filter) list_bufnr = vim.api.nvim_get_current_buf() vim.keymap.set("n", "", jump, { buffer = list_bufnr, desc = "Jump to comment" }) vim.keymap.set("n", "d", delete_selected, { buffer = list_bufnr, desc = "Delete comment" }) + vim.keymap.set("n", "e", edit_selected, { buffer = list_bufnr, desc = "Edit comment" }) + return true end return M diff --git a/lua/scratch_comments/navigate.lua b/lua/scratch_comments/navigate.lua index feabb6c..0ccff01 100644 --- a/lua/scratch_comments/navigate.lua +++ b/lua/scratch_comments/navigate.lua @@ -8,6 +8,12 @@ local M = {} ---@param current ScratchCommentWindow ---@param direction 1|-1 local function cycle(current, direction) + -- Manager-opened cards never cycle in a source file, even if the quickfix + -- window that opened them has since been closed. + if current.from_list then + notify.info("Jump to the file to navigate its comments") + return + end if vim.bo[vim.api.nvim_win_get_buf(current.comment_win)].modified then notify.warn("Save or discard the comment first") return diff --git a/lua/scratch_comments/ui/frame.lua b/lua/scratch_comments/ui/frame.lua index 49feb8c..1c2f9b5 100644 --- a/lua/scratch_comments/ui/frame.lua +++ b/lua/scratch_comments/ui/frame.lua @@ -21,7 +21,7 @@ end ---@field filetype string ---@field comment string ---@field comment_name? string name shown for the editable comment buffer ----@field on_save? fun(text: string): boolean? false keeps the text unsaved +---@field on_save? fun(text: string): boolean|string? false keeps the text unsaved; "deleted" closes the editor ---@field comment_title? string ---@field on_close? fun() ---@field enter? boolean focus the comment pane (default: true) diff --git a/lua/scratch_comments/ui/quickfix.lua b/lua/scratch_comments/ui/quickfix.lua index 024796d..c799048 100644 --- a/lua/scratch_comments/ui/quickfix.lua +++ b/lua/scratch_comments/ui/quickfix.lua @@ -47,13 +47,8 @@ end ---@param views ScratchCommentView[] ---@param orphans ScratchComment[] ----@param on_entry? fun(index: integer?) called with the entry the cursor is on -function M.list(views, orphans, on_entry) - if #views + #orphans == 0 then - notify.info("No comments") - return - end - +---@return table[] +function M.items(views, orphans) local entries = vim.tbl_map(function(comment) return { filename = comment.source_name, @@ -73,7 +68,19 @@ function M.list(views, orphans, on_entry) }) end - vim.fn.setqflist({}, " ", { title = "Comments", items = entries }) + return entries +end + +---@param views ScratchCommentView[] +---@param orphans ScratchComment[] +---@param on_entry? fun(index: integer?) called with the entry the cursor is on +function M.list(views, orphans, on_entry) + if #views + #orphans == 0 then + notify.info("No comments") + return + end + + vim.fn.setqflist({}, " ", { title = "Comments", items = M.items(views, orphans) }) vim.cmd.copen() follow(on_entry) return vim.fn.getqflist({ id = 0 }).id diff --git a/lua/scratch_comments/ui/window.lua b/lua/scratch_comments/ui/window.lua index 263fefb..9676eab 100644 --- a/lua/scratch_comments/ui/window.lua +++ b/lua/scratch_comments/ui/window.lua @@ -16,10 +16,14 @@ local mode_names = { ---@class ScratchCommentWindow ---@field id? string unset until a new comment is saved ----@field bufnr integer +---@field bufnr? integer +---@field source_name? string ---@field line integer ---@field source_win integer ---@field comment_win? integer +---@field on_close? fun() +---@field on_saved? fun() +---@field from_list? boolean ---@type ScratchCommentWindow? local current @@ -32,8 +36,14 @@ function M.open(spec, record) if current and current.comment_win == record.comment_win then current = nil end + if record.on_close then + record.on_close() + end end - local source_name = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(record.bufnr), ":t") + local source_buffer_name = record.source_name + or (record.bufnr and vim.api.nvim_buf_get_name(record.bufnr)) + or "comment" + local source_name = vim.fn.fnamemodify(source_buffer_name, ":t") spec.comment_name = source_name .. ":" .. record.line .. " [comment]" local panes = frame.open(spec) local comment_win, comment_buf = panes.comment_win, panes.comment_buf @@ -73,10 +83,21 @@ function M.open(spec, record) callback = function() local lines = vim.api.nvim_buf_get_lines(comment_buf, 0, -1, false) local text = vim.trim(table.concat(lines, "\n")) - if text ~= "" and spec.on_save and spec.on_save(text) == false then + local result = spec.on_save and spec.on_save(text) + if result == false then return end vim.bo[comment_buf].modified = false + if record.on_saved then + record.on_saved() + end + if result == "deleted" then + vim.schedule(function() + if vim.api.nvim_win_is_valid(comment_win) then + vim.api.nvim_win_close(comment_win, true) + end + end) + end end, }) end @@ -91,6 +112,9 @@ function M.close() if vim.bo[vim.api.nvim_win_get_buf(open.comment_win)].modified then return false end + -- A command replacing the card (e.g. :CommentExport!) owns the next focus. + -- Only a user closing the card should return to its list. + open.on_close = nil pcall(vim.api.nvim_win_close, open.comment_win, true) return true end diff --git a/plugin/scratch-comments.lua b/plugin/scratch-comments.lua index 1ead0a7..f54ad05 100644 --- a/plugin/scratch-comments.lua +++ b/plugin/scratch-comments.lua @@ -17,10 +17,6 @@ command("CommentShow", function() scratch().show() end, { desc = "Show the comments at the cursor" }) -command("CommentDelete", function() - scratch().delete() -end, { desc = "Delete the comment at the cursor" }) - command("CommentNext", function() scratch().next() end, { desc = "Go to the next comment" }) diff --git a/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua index 1d5d5a6..8f513bd 100644 --- a/tests/features/anchoring_spec.lua +++ b/tests/features/anchoring_spec.lua @@ -127,11 +127,9 @@ describe("an orphan", function() expect.equality(only().start_line, 3) end) - it("is offered by :CommentDelete on a line without comments", function() - helpers.pick(1) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - vim.cmd("CommentDelete") - expect.equality(#helpers.offered, 1) + it("can be deleted from the comment list", function() + vim.cmd.CommentList() + vim.cmd("normal d") expect.equality(#views.orphans(), 0) end) end) @@ -237,9 +235,8 @@ describe("a file's comments", function() vim.wait(0) expect.equality(#views.orphans(), 1) expect.equality(#views.anchored(), 0) - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - helpers.pick(1) - vim.cmd.CommentDelete() + vim.cmd.CommentList() + vim.cmd("normal d") expect.equality(#store.all(), 0) end) diff --git a/tests/features/commenting_spec.lua b/tests/features/commenting_spec.lua index 73cd311..6c500ed 100644 --- a/tests/features/commenting_spec.lua +++ b/tests/features/commenting_spec.lua @@ -72,6 +72,26 @@ describe("the comment window", function() expect.equality(store.all()[1].comment, "first line\nsecond line, revised") end) + it("adds nothing when a new empty comment is saved", function() + vim.cmd("Comment") + vim.cmd.stopinsert() + vim.cmd.write() + vim.cmd.quit() + expect.equality(#store.all(), 0) + end) + + it("deletes a comment when saved empty after its first write", function() + vim.cmd("Comment") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "first" }) + vim.cmd.write() + expect.equality(#store.all(), 1) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) + vim.cmd.write() + vim.wait(0) + expect.equality(#store.all(), 0) + expect.equality(#helpers.floats(), 0) + end) + it("adds nothing when discarded with :q!", function() vim.cmd("Comment") vim.api.nvim_buf_set_lines(0, 0, -1, false, { "hello" }) diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index 28820e3..7c8eeba 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -105,6 +105,82 @@ describe(":CommentList", function() end) describe("the comment list manager", function() + it("edits a comment in the same card, then refreshes the list", function() + vim.cmd("CommentList") + local id = vim.fn.getqflist()[1].user_data.scratch_comments_id + vim.cmd("normal e") + expect.equality(helpers.text(), "on a") + expect.equality(vim.bo.modifiable, true) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "revised" }) + vim.cmd.write() + expect.equality(vim.fn.getqflist()[1].text, "revised") + vim.cmd.quit() + vim.wait(0) + expect.equality(vim.bo.buftype, "quickfix") + expect.equality(vim.fn.getqflist()[1].text, "revised") + expect.equality(vim.fn.getqflist()[1].user_data.scratch_comments_id, id) + vim.cmd.cclose() + end) + + it("doesn't reopen a list the user closed while editing", function() + vim.cmd.CommentList() + vim.cmd("normal e") + vim.cmd.cclose() + vim.cmd.quit() + vim.wait(0) + expect.equality(vim.fn.getqflist({ winid = 0 }).winid, 0) + end) + + it("doesn't navigate a file from a manager-opened card", function() + vim.cmd.CommentList() + vim.cmd("normal e") + vim.cmd.cclose() + vim.cmd.CommentNext() + expect.equality(helpers.text(), "on a") + expect.equality(#helpers.floats(), 2) + vim.cmd.quit() + end) + + it("keeps focus on an export replacing a manager-opened card", function() + vim.cmd.CommentList() + vim.cmd("normal e") + vim.cmd("CommentExport!") + vim.wait(0) + expect.equality(vim.bo.buftype, "nofile") + expect.equality(vim.bo.filetype, "markdown") + vim.cmd.cclose() + end) + + it("edits a comment whose source was wiped", function() + vim.cmd("bwipeout! " .. source) + vim.cmd.CommentList() + vim.cmd("normal e") + expect.equality(helpers.text(), "on a") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "revised parked" }) + vim.cmd("wq") + vim.wait(0) + expect.equality(vim.fn.getqflist()[1].text, "revised parked") + expect.equality(store.all()[2].state, "parked") + vim.cmd.cclose() + end) + + it("deletes an orphan by saving its card empty", function() + vim.api.nvim_set_current_buf(source) + vim.cmd("1d") + helpers.text_changed() + vim.cmd.CommentList() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.cmd("normal e") + expect.equality(helpers.text(), "on a") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) + vim.cmd.write() + vim.wait(0) + expect.equality(#store.all(), 1) + expect.equality(vim.bo.buftype, "quickfix") + expect.equality(#vim.fn.getqflist(), 1) + vim.cmd.cclose() + end) + it("deletes the selected comment and keeps the filtered list", function() vim.cmd("CommentList export-fixture") local removed = vim.fn.getqflist()[1].user_data.scratch_comments_id diff --git a/tests/features/navigation_spec.lua b/tests/features/navigation_spec.lua index 830c2f4..8c7fa4c 100644 --- a/tests/features/navigation_spec.lua +++ b/tests/features/navigation_spec.lua @@ -117,7 +117,9 @@ describe("when things change under an open comment", function() vim.api.nvim_win_set_cursor(0, { 2, 0 }) vim.cmd("CommentShow") vim.api.nvim_set_current_win(file_win) - vim.cmd("CommentDelete") + local comments = require("scratch_comments.comments") + local store = require("scratch_comments.model.store") + comments.delete(store.all()[1]) vim.cmd("CommentNext") expect.equality(helpers.text(), "also on four") end) diff --git a/tests/features/plugin_spec.lua b/tests/features/plugin_spec.lua index 662cd1d..bf6cf69 100644 --- a/tests/features/plugin_spec.lua +++ b/tests/features/plugin_spec.lua @@ -24,6 +24,10 @@ describe("the plugin", function() expect.equality(fresh("io.write(vim.fn.exists(':CommentShow'))"), "2") end) + it("has no :CommentDelete command", function() + expect.equality(fresh("io.write(vim.fn.exists(':CommentDelete'))"), "0") + end) + it("loads nothing until a command runs", function() local loaded = "io.write(tostring(package.loaded['scratch_comments.actions'] ~= nil))" expect.equality(fresh(loaded), "false") diff --git a/tests/features/show_delete_spec.lua b/tests/features/show_delete_spec.lua index 1f1a912..a88c548 100644 --- a/tests/features/show_delete_spec.lua +++ b/tests/features/show_delete_spec.lua @@ -65,26 +65,25 @@ describe("a comment that is gone", function() end) end) -describe(":CommentDelete", function() - it("deletes the picked comment", function() +describe("saving an empty comment", function() + it("deletes the edited comment and closes its card", function() helpers.pick(1) - vim.cmd("CommentDelete") + vim.cmd("CommentShow") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) + vim.cmd.write() + vim.wait(0) expect.equality(#store.all(), 1) expect.equality(views.anchored()[1].comment, "on one to three") + expect.equality(#helpers.floats(), 0) end) - it("deletes nothing when the picker is cancelled", function() - helpers.pick(0) - vim.cmd("CommentDelete") + it("does not delete it when empty changes are discarded", function() + helpers.pick(1) + vim.cmd("CommentShow") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) + vim.cmd("q!") expect.equality(#store.all(), 2) end) - - it("says so on a line with no comment and no orphans", function() - vim.api.nvim_win_set_cursor(0, { 4, 0 }) - vim.cmd("CommentDelete") - expect.equality(#store.all(), 2) - expect.equality(helpers.notified()[#helpers.notified()], "No comment at cursor") - end) end) describe(":CommentClear", function() -- 2.54.0 From 071a04582b12ac0b129f0ed01862ce5f8c1264fd Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 30 Sep 2026 05:13:02 -0700 Subject: [PATCH 08/13] Make the comment list the sole review manager --- README.md | 48 +++--- doc/scratch-comments.txt | 64 +++----- lua/scratch_comments/actions.lua | 71 +-------- lua/scratch_comments/init.lua | 11 +- lua/scratch_comments/list.lua | 219 ++++++++++++++++++++-------- lua/scratch_comments/navigate.lua | 98 ------------- lua/scratch_comments/ui/frame.lua | 39 ++++- lua/scratch_comments/ui/preview.lua | 62 ++++++-- lua/scratch_comments/ui/window.lua | 35 ++++- plugin/scratch-comments.lua | 14 +- tests/features/anchoring_spec.lua | 4 +- tests/features/commenting_spec.lua | 10 +- tests/features/list_export_spec.lua | 195 ++++++++++++++++++++++--- tests/features/navigation_spec.lua | 136 ----------------- tests/features/plugin_spec.lua | 9 +- tests/features/show_delete_spec.lua | 95 ++++++------ tests/features/spans_spec.lua | 7 +- tests/features/window_spec.lua | 18 ++- 18 files changed, 567 insertions(+), 568 deletions(-) delete mode 100644 lua/scratch_comments/navigate.lua delete mode 100644 tests/features/navigation_spec.lua diff --git a/README.md b/README.md index fa8b62d..8074c13 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,7 @@ throws your changes away. `w` moves between the two parts, or scroll them w mouse. commented lines get a mark in the sign column: `│` for one line, and `╭` `│` `╰` -down a range. to read the comment on the cursor line: - -```vim -:CommentShow -``` - -browse every comment, then copy them all: +down a range. browse your comments, then copy them all: ```vim :CommentList @@ -84,10 +78,8 @@ it, pipe it to a command, or edit it: | Command | Does | | --- | --- | | `:Comment` | Comment on the current line or command range | -| `:CommentShow` | Show the comment at the cursor, with the lines it's on. Edit it there and `:w` to save | -| `:CommentNext` | Go to the next comment in the file | -| `:CommentPrev` | Go to the previous comment in the file | | `:CommentList [filter]` | Put comments in the quickfix list and open it, fuzzy matching `filter` | +| `:CommentDelete` | Delete the comment in the open card | | `:CommentExport[!] [format]` | Copy all comments to the clipboard as `markdown` (default) or `json`. `!` opens them in a scratch buffer | | `:CommentToggle [on\|off]` | Show or hide the comment signs | | `:CommentClear` | Delete every comment | @@ -99,25 +91,20 @@ add your own, for example: ```lua vim.keymap.set("n", "ca", "Comment", { desc = "Comment on line" }) vim.keymap.set("x", "ca", ":Comment", { desc = "Comment on selection" }) -vim.keymap.set("n", "cs", "CommentShow", { desc = "Show comments" }) -vim.keymap.set("n", "cn", "CommentNext", { desc = "Next comment" }) -vim.keymap.set("n", "cp", "CommentPrev", { desc = "Previous comment" }) vim.keymap.set("n", "cl", "CommentList", { desc = "List comments" }) vim.keymap.set("n", "cx", "CommentExport", { desc = "Copy comments" }) ``` ## browsing -`:CommentNext` and `:CommentPrev` move between comments in the current file, -wrapping at the ends. with a comment open, they show the next or previous -comment in that window instead. save or discard your changes first. - -`:CommentList` puts all your comments in the quickfix list, so `]q` and `[q` -move between them across files. while the list is open, the comment under the -cursor is shown in a card above it. in the built-in list, press `` to go -to a comment (even in a closed file), `e` to edit it, or `d` to delete it. -give it an argument to list only the comments that fuzzy match it, in their -text, snippet or path: +`:CommentList` puts all your comments in the quickfix list. move through them +with `j`/`k` to preview a card without moving the source cursor. `` jumps +to the source when the comment has a location, and focuses the same card for +editing. `:w` saves; `:CommentDelete` deletes the comment shown in a card, +from either the card or the list. `` dismisses a clean card in either +place; unsaved edits need `:w` or `:q!`. an orphan has no line to jump to, so +its editor returns to the list. give `:CommentList` an argument to fuzzy match +comments by text, snippet or path: ```vim :CommentList typo @@ -136,8 +123,8 @@ any quickfix viewer works: comments stay when you close a buffer (`:bd`, `:bw`). open the same source again and they'll pick up where they left off if the text is still on the same -lines. delete one with `d` in the list, or save it empty from its comment -window. `:CommentClear` deletes them all. to keep them after exiting neovim, +lines. delete one with `:CommentDelete` while its card is open, or save its +comment empty. `:CommentClear` deletes them all. to keep them after exiting neovim, export them to a file. the signs share the sign column with plugins like gitsigns, and can cover their @@ -145,7 +132,8 @@ signs. `:CommentToggle` hides ours when you need to see theirs. if the lines a comment is on are deleted, it becomes an orphan. orphans are listed last in `:CommentList` and in an "Orphaned" section of the export. undo -restores them while the buffer is open. delete one with `d` in the list. +restores them while the buffer is open. delete one with `:CommentDelete` +while its card is open. ## colors @@ -173,9 +161,7 @@ vim.api.nvim_set_hl(0, "ScratchCommentBackdrop", { bg = "NONE" }) local scratch = require("scratch_comments") scratch.add(start_line, end_line) -- default: the cursor line -scratch.show() -scratch.next() -scratch.prev() +scratch.delete() -- comment in the open card scratch.list() scratch.render(format) -- "markdown" (default) or "json" scratch.export(format, in_buffer) -- copy, or open in a scratch buffer @@ -201,8 +187,8 @@ vim.fn.writefile(vim.split(scratch.render(), "\n"), "review.md") plugin/scratch-comments.lua the commands lua/scratch_comments/ init.lua the lua API - actions.lua comment, show, delete, toggle, clear - navigate.lua next and prev + actions.lua add and edit comments, toggle signs, clear + list.lua browse, navigate and delete comments comments.lua changes comments and keeps the signs in sync location.lua "line 3", "lines 3–5", "line 3, col 5–12" paths.lua git root and relative paths diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index c49232d..b3c6a16 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -15,7 +15,7 @@ CONTENTS *scratch-comments-contents* Comment on any lines in any file, then copy the comments out as Markdown or JSON to paste into an LLM or give to a person. Commented lines get a sign, -and |:CommentShow| shows the comment on the cursor line. +and |:CommentList| lets you browse and edit them. Comments live in memory for the Neovim session. Closing a buffer does not delete its comments; |:CommentClear| or exiting Neovim does. @@ -34,7 +34,7 @@ Requires Neovim 0.12 or newer. gives ":'<,'>Comment". A characterwise selection (|v|) comments on just the selected text. A linewise one (|V|) comments on whole lines. If that exact text - already has a comment, edit it. + already has a comment, use |:CommentList| to edit it. A window opens in the middle of the screen: the lines you are commenting on at the top, your comment below. @@ -45,43 +45,28 @@ Requires Neovim 0.12 or newer. |CTRL-W_w| moves between the two parts, or scroll them with the mouse. - *:CommentShow* -:CommentShow Open the comment at the cursor, with the lines it is - on, in the same window |:Comment| uses. If more than - one comment covers the line, asks which one. It opens - in normal mode. Edit the comment and |:w| to save, or - |:q| to close it. Saving it empty deletes the comment. - - *:CommentNext* -:CommentNext Go to the next comment in the current file, wrapping - at the end. Comments that start on the same line are - one stop. - - With a comment open, show the next comment in that - window instead. Every comment is a stop, even on the - same line, and the cursor in the file follows. Save - or discard your changes first. - - *:CommentPrev* -:CommentPrev Go to the previous comment in the current file, - wrapping at the start. With a comment open, show the - previous comment in that window instead. - *:CommentList* :CommentList [filter] Put every comment in the |quickfix| list and open it. With [filter], only the comments that fuzzy match it in their text, snippet or path (see |matchfuzzy()|). - |]q| and |[q| then move between comments across files. - For a comment on selected text, they land on the text. - - While the list is open, the comment the cursor is on - is shown in a card above it, with the lines it is on. - Press to jump to the source (even if its buffer - was closed), e to edit in the comment card, or d to - delete the comment. The card goes away with the list. + Moving through the built-in list previews a card + without moving the source cursor. jumps to the + comment's source location and focuses that same card + for editing in normal mode. An orphan has no location; + edits it without a jump. |:w| saves; saving it + empty deletes it. dismisses a clean card from + the list or the card; unsaved changes require |:w| or + |:q!|. The preview goes away with the list. Other quickfix front-ends (trouble.nvim, nvim-bqf, Snacks.picker.qflist, :Telescope quickfix) can show - the entries; these keys belong to the built-in list. + the entries; and belong to the built-in + list only. + + *:CommentDelete* +:CommentDelete Delete the comment shown in an open card, whether + focus is in the card or the built-in quickfix list. + With no card open, nothing is deleted. Save or discard + unsaved edits first. *:CommentExport* :CommentExport[!] [format] @@ -105,7 +90,8 @@ Requires Neovim 0.12 or newer. A comment belongs to a file and a range of lines, or to the exact text you selected within them. -- Commenting on the same lines again edits that comment. +- Commenting on the same lines again does not add a duplicate; edit it from + |:CommentList|. - Commenting on different lines adds a new comment, even if the lines overlap another comment. A comment on lines 10–30 and one on line 14 are separate. @@ -126,8 +112,8 @@ If you delete all the lines a comment is on, the comment becomes inactive (an orphan). A completed ordinary file reload with mismatched text also makes a parked comment inactive. Inactive comments are listed last in |:CommentList| and in an "Orphaned" section of the export; |undo| restores an extmark whose -buffer is still present. Delete an inactive comment with d in the list, or -open its card with e and save an empty comment. +buffer is still present. Delete an inactive comment with |:CommentDelete| +while its card is open, or open its card with and save it empty. In tmux you may need to set the clipboard to OSC 52 yourself: >lua vim.g.clipboard = "osc52" @@ -162,9 +148,7 @@ needs 'termguicolors'. For a different dim, or none: >lua local scratch = require("scratch_comments") scratch.add(start_line, end_line) -- default: the cursor line - scratch.show() - scratch.next() - scratch.prev() + scratch.delete() -- comment in the open card scratch.list() scratch.render(format) -- "markdown" (default) or "json" scratch.export(format, in_buffer) -- copy, or open in a scratch buffer @@ -174,7 +158,7 @@ needs 'termguicolors'. For a different dim, or none: >lua Example mappings: >lua vim.keymap.set("n", "ca", "Comment") vim.keymap.set("x", "ca", ":Comment") - vim.keymap.set("n", "cs", "CommentShow") + vim.keymap.set("n", "cl", "CommentList") vim.keymap.set("n", "cx", "CommentExport") < vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua index 65110b5..6d224a0 100644 --- a/lua/scratch_comments/actions.lua +++ b/lua/scratch_comments/actions.lua @@ -10,12 +10,6 @@ local window = require("scratch_comments.ui.window") local M = {} ----@param text string ----@return string -local function summary(text) - return vim.split(text, "\n")[1] -end - ---@param bufnr integer ---@param start_line integer ---@param end_line integer @@ -46,14 +40,6 @@ local function selected_columns(start_line, end_line) return from[3] - 1, end_col end ----@return ScratchCommentView[] -local function cursor_comments() - local line = vim.api.nvim_win_get_cursor(0)[1] - return vim.tbl_filter(function(view) - return view.start_line <= line and line <= view.end_line - end, views.in_buffer(vim.api.nvim_get_current_buf())) -end - ---@param id string ---@param text string ---@return boolean|string? "deleted" when saving removes the comment @@ -76,10 +62,10 @@ end ---@param view ScratchComment|ScratchCommentView ---@param source_win integer +---@param panes ScratchFramePanes? visible preview to focus ---@param on_close? fun() ---@param on_saved? fun() ----@param from_list? boolean -function M.show(view, source_win, on_close, on_saved, from_list) +function M.show(view, source_win, panes, on_close, on_saved) local frame if view.start_line then frame = card.of(view --[[@as ScratchCommentView]]) @@ -97,24 +83,7 @@ function M.show(view, source_win, on_close, on_saved, from_list) source_win = source_win, on_close = on_close, on_saved = on_saved, - from_list = from_list, - }) -end - ----@param id string ----@param on_close? fun() ----@param on_saved? fun() -function M.show_by_id(id, on_close, on_saved) - local view = vim.iter(views.anchored()):find(function(entry) - return entry.id == id - end) or vim.iter(views.orphans()):find(function(entry) - return entry.id == id - end) - if view then - M.show(view, vim.api.nvim_get_current_win(), on_close, on_saved, true) - else - notify.warn("This comment is gone") - end + }, panes) end -- Opens an editor for a comment that does not exist yet; it is created when @@ -178,45 +147,13 @@ function M.comment(start_line, end_line, use_selection) local existing = find_anchor(bufnr, where.start_line, where.end_line, where.start_col, where.end_col) if existing then - M.show(existing, vim.api.nvim_get_current_win()) + notify.info("This range already has a comment; edit it from :CommentList") return end edit_new(bufnr, paths.is_uri(name) and name or vim.fn.fnamemodify(name, ":p"), where) end ----@param view ScratchCommentView ----@return string -local function describe(view) - return location.describe(view) .. ": " .. summary(view.comment) -end - ----@param candidates ScratchCommentView[] ----@param callback fun(view: ScratchCommentView) -local function choose(candidates, callback) - if #candidates == 1 then - callback(candidates[1]) - return - end - vim.ui.select(candidates, { prompt = "Comment: ", format_item = describe }, function(view) - if view then - callback(view) - end - end) -end - -function M.show_current() - local at_cursor = cursor_comments() - if #at_cursor == 0 then - notify.info("No comment at cursor") - return - end - local source_win = vim.api.nvim_get_current_win() - choose(at_cursor, function(view) - M.show(view, source_win) - end) -end - ---@param on? boolean function M.toggle(on) if on == nil then diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index 0fd3f71..a6150d8 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -1,7 +1,6 @@ local actions = require("scratch_comments.actions") local export = require("scratch_comments.export") local list = require("scratch_comments.list") -local navigate = require("scratch_comments.navigate") local M = {} @@ -12,21 +11,13 @@ function M.add(start_line, end_line) actions.comment(start_line, end_line or start_line) end -M.show = actions.show_current M.toggle = actions.toggle M.clear = actions.clear M.render = export.render M.export = export.export -function M.next() - navigate.jump(1) -end - -function M.prev() - navigate.jump(-1) -end - M.list = list.open +M.delete = list.delete_open -- Kept so configs that call it keep working; the commands need no setup. function M.setup() end diff --git a/lua/scratch_comments/list.lua b/lua/scratch_comments/list.lua index 58846d3..c1bec5e 100644 --- a/lua/scratch_comments/list.lua +++ b/lua/scratch_comments/list.lua @@ -15,6 +15,8 @@ local list_bufnr local active_filter local pending_jump = {} local listening = {} +local dismissed_id +local refresh ---@param comment ScratchComment|ScratchCommentView ---@return string @@ -98,33 +100,82 @@ local function jump() notify.warn("This comment is gone") return end - if - comment.state == "inactive" - or ( - comment.state == nil - and #vim.tbl_filter(function(c) - return c.id == id - end, views.orphans()) > 0 - ) - then - notify.warn("This comment no longer has a location") + if not window.close() then + notify.warn("Save or discard the comment first") return end - local ok = pcall(function() - vim.cmd(index .. "cc") + local qf_win = vim.api.nvim_get_current_win() + local entry = vim.iter(views.anchored()):find(function(view) + return view.id == id + end) or vim.iter(views.orphans()):find(function(view) + return view.id == id end) - if not ok then - notify.warn("Could not open the comment source") + if not entry then + notify.warn("This comment is gone") return end - local bufnr, win = vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() - if vim.api.nvim_buf_get_name(bufnr) ~= comment.source_name then + -- Claim the visible panes before :cc triggers BufLeave on the quickfix list. + -- Enter changes focus and writeability, never the card's placement. + if preview.current_id() ~= id then + preview.show(entry.start_line and card.of(entry) or card.of_orphan(entry), id) + end + local panes = preview.take() + local owner = qf_win + local bufnr, win + if entry.start_line then + local ok = pcall(function() + vim.cmd(index .. "cc") + end) + if ok and vim.api.nvim_buf_get_name(0) == comment.source_name then + bufnr, win = vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() + owner = win + else + notify.warn("Could not open the comment source") + end + end + local expected_id = list_id + if bufnr then + -- A completed read may invalidate a parked anchor while :cc opens it. + local resolved = vim.iter(views.orphans()):find(function(orphan) + return orphan.id == id + end) + if resolved then + entry = resolved + bufnr, win, owner = nil, nil, qf_win + vim.api.nvim_set_current_win(qf_win) + end + end + actions.show(entry, owner, panes, function() + vim.schedule(function() + if owner == qf_win and vim.fn.getqflist({ id = 0 }).id ~= expected_id then + return + end + if vim.api.nvim_win_is_valid(owner) then + vim.api.nvim_set_current_win(owner) + end + end) + end, function() + refresh(id, index, expected_id) + end) + if not bufnr or not win then return end local function position() if not vim.api.nvim_win_is_valid(win) or vim.api.nvim_win_get_buf(win) ~= bufnr then return true end + local active = window.current() + if + vim.api.nvim_get_current_win() ~= win + and not ( + active + and active.id == id + and active.source_win == win + and vim.api.nvim_get_current_win() == active.comment_win + ) + then + return true + end local current = by_id(id) if not current then return true @@ -137,6 +188,12 @@ local function jump() return true end if current.state == "inactive" then + local open = window.current() + if open and open.id == id and open.update then + open.update(card.of_orphan(current)) + open.source_win, owner = qf_win, qf_win + refresh(id, index, expected_id) + end notify.warn("This comment no longer has a location") return true end @@ -148,15 +205,25 @@ local function jump() -- An async BufReadCmd may fill the buffer after :cc has already moved the -- cursor. Keep one listener per buffer even if the user jumps repeatedly. pending_jump[bufnr] = position - vim.api.nvim_create_autocmd("BufLeave", { - buffer = bufnr, - once = true, - callback = function() - if pending_jump[bufnr] == position then - pending_jump[bufnr] = nil - end - end, - }) + local open = window.current() + if open then + vim.api.nvim_create_autocmd("BufLeave", { + buffer = vim.api.nvim_win_get_buf(open.comment_win), + once = true, + callback = function() + if pending_jump[bufnr] == position then + pending_jump[bufnr] = nil + end + end, + }) + end + -- BufReadPost reattachment runs on the next scheduled turn. Recheck after + -- it, even if the file was fully populated before our on_lines listener. + vim.schedule(function() + if pending_jump[bufnr] == position and position() then + pending_jump[bufnr] = nil + end + end) if listening[bufnr] then return end @@ -185,7 +252,7 @@ end ---@param id string ---@param index integer ---@param expected_id integer -local function refresh(id, index, expected_id) +refresh = function(id, index, expected_id) local qf = vim.fn.getqflist({ id = 0, winid = 0 }) if qf.id ~= expected_id or qf.winid == 0 then return @@ -211,33 +278,18 @@ local function refresh(id, index, expected_id) vim.api.nvim_win_set_cursor(qf.winid, { row, 0 }) end -local function edit_selected() - local index, id = selected() - if not index or not id or not by_id(id) then - notify.warn("This comment is gone") - return - end - local expected_id = list_id - actions.show_by_id(id, function() - vim.schedule(function() - local qf = vim.fn.getqflist({ id = 0, winid = 0 }) - if qf.id == expected_id and qf.winid ~= 0 and vim.api.nvim_win_is_valid(qf.winid) then - vim.api.nvim_set_current_win(qf.winid) - end - end) - end, function() - refresh(id, index, expected_id) - end) -end - -local function delete_selected() - local index, id = selected() - if not index then - notify.warn("Not a Scratch Comments list") - return - end +-- Delete only the comment displayed in a card, never one inferred from the +-- cursor in an arbitrary file or an unrelated quickfix list. +function M.delete_open() + local open = window.current() + local index, selected_id = selected() + local id = open and open.id or (selected_id and preview.current_id()) if not id then - notify.warn("This comment is gone") + notify.warn("No comment card open") + return + end + if open and vim.bo[vim.api.nvim_win_get_buf(open.comment_win)].modified then + notify.warn("Save or discard the comment first") return end local comment = by_id(id) @@ -245,12 +297,42 @@ local function delete_selected() notify.warn("This comment is gone") return end - comments.delete(comment) - local anchored, orphans = filtered(active_filter) - if M.open(active_filter) then - vim.api.nvim_win_set_cursor(0, { math.min(index, #anchored + #orphans), 0 }) - vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) + if not open then + preview.close() + dismissed_id = id end + comments.delete(comment) + notify.info("Deleted comment") + if index then + refresh(id, index, list_id) + elseif open and open.on_saved then + open.on_saved() + end + if open then + if index then + -- Deleting from the list leaves focus there, even if its card was focused. + window.close() + else + vim.api.nvim_win_close(open.comment_win, true) + end + end +end + +local function escape() + local index, id = selected() + if not index then + return + end + if window.current() then + if not window.close() then + notify.warn("Save with :w, or discard with :q!") + return + end + end + if preview.is_open() then + preview.close() + end + dismissed_id = id end -- Every comment in the quickfix list, with the one under the cursor shown @@ -261,7 +343,11 @@ function M.open(filter) notify.warn("Save or discard the comment first") return end + for bufnr in pairs(pending_jump) do + pending_jump[bufnr] = nil + end active_filter = filter + dismissed_id = nil local anchored, orphans = filtered(filter) if #anchored + #orphans == 0 then if list_id and vim.fn.getqflist({ id = 0 }).id == list_id then @@ -274,6 +360,10 @@ function M.open(filter) return false end list_id = quickfix.list(anchored, orphans, function(index) + if not index then + preview.close() + return + end local qf = vim.fn.getqflist({ items = 1 }) local data = index and qf.items[index] and qf.items[index].user_data local id = data and data.scratch_comments_id @@ -281,15 +371,21 @@ function M.open(filter) and vim.iter(views.anchored()):find(function(view) return view.id == id end) + if id ~= dismissed_id then + dismissed_id = nil + end + if window.current() or dismissed_id == id then + return + end if entry then - preview.show(card.of(entry)) + preview.show(card.of(entry), id) else local orphan = id and vim.iter(views.orphans()):find(function(comment) return comment.id == id end) if orphan then - preview.show(card.of_orphan(orphan)) + preview.show(card.of_orphan(orphan), id) else preview.close() end @@ -299,9 +395,8 @@ function M.open(filter) return end list_bufnr = vim.api.nvim_get_current_buf() - vim.keymap.set("n", "", jump, { buffer = list_bufnr, desc = "Jump to comment" }) - vim.keymap.set("n", "d", delete_selected, { buffer = list_bufnr, desc = "Delete comment" }) - vim.keymap.set("n", "e", edit_selected, { buffer = list_bufnr, desc = "Edit comment" }) + vim.keymap.set("n", "", jump, { buffer = list_bufnr, desc = "Jump and edit comment" }) + vim.keymap.set("n", "", escape, { buffer = list_bufnr, desc = "Dismiss comment card" }) return true end diff --git a/lua/scratch_comments/navigate.lua b/lua/scratch_comments/navigate.lua deleted file mode 100644 index 0ccff01..0000000 --- a/lua/scratch_comments/navigate.lua +++ /dev/null @@ -1,98 +0,0 @@ -local actions = require("scratch_comments.actions") -local notify = require("scratch_comments.ui.notify") -local views = require("scratch_comments.model.views") -local window = require("scratch_comments.ui.window") - -local M = {} - ----@param current ScratchCommentWindow ----@param direction 1|-1 -local function cycle(current, direction) - -- Manager-opened cards never cycle in a source file, even if the quickfix - -- window that opened them has since been closed. - if current.from_list then - notify.info("Jump to the file to navigate its comments") - return - end - if vim.bo[vim.api.nvim_win_get_buf(current.comment_win)].modified then - notify.warn("Save or discard the comment first") - return - end - local in_file = views.in_buffer(current.bufnr) - if #in_file == 0 then - return - end - - local index = views.index_of(in_file, current.id) - local target - if index then - target = in_file[(index - 1 + direction) % #in_file + 1] - elseif direction == 1 then - target = vim.iter(in_file):find(function(view) - return view.start_line > current.line - end) or in_file[1] - else - target = vim.iter(in_file):rev():find(function(view) - return view.start_line < current.line - end) or in_file[#in_file] - end - - vim.api.nvim_win_close(current.comment_win, true) - if vim.api.nvim_win_is_valid(current.source_win) then - vim.api.nvim_win_call(current.source_win, function() - vim.cmd("normal! m'") - vim.api.nvim_win_set_cursor(0, { target.start_line, target.start_col or 0 }) - end) - end - actions.show(target, current.source_win) -end - ----@param direction 1|-1 -local function move_cursor(direction) - local bufnr = vim.api.nvim_get_current_buf() - local line = vim.api.nvim_win_get_cursor(0)[1] - local starts = {} - for _, view in ipairs(views.in_buffer(bufnr)) do - starts[view.start_line] = true - end - local lines = vim.tbl_keys(starts) - if #lines == 0 then - notify.info("No comments in this buffer") - return - end - table.sort(lines) - - local target - if direction == 1 then - for _, start_line in ipairs(lines) do - if start_line > line then - target = start_line - break - end - end - target = target or lines[1] - else - for i = #lines, 1, -1 do - if lines[i] < line then - target = lines[i] - break - end - end - target = target or lines[#lines] - end - - vim.cmd("normal! m'") - vim.api.nvim_win_set_cursor(0, { target, 0 }) -end - ----@param direction 1|-1 -function M.jump(direction) - local current = window.current() - if current then - cycle(current, direction) - else - move_cursor(direction) - end -end - -return M diff --git a/lua/scratch_comments/ui/frame.lua b/lua/scratch_comments/ui/frame.lua index 1c2f9b5..bcd5553 100644 --- a/lua/scratch_comments/ui/frame.lua +++ b/lua/scratch_comments/ui/frame.lua @@ -34,21 +34,20 @@ end ---@field comment_win integer ---@field comment_buf integer ---@field backdrop_win? integer +---@field update fun(spec: ScratchFrame, preserve_comment?: boolean) +---@field lines? integer preview area retained when focusing +---@field backdrop? boolean preview dimming retained when focusing -- Two stacked floats, centered: the commented lines above, the comment below. ---@param frame ScratchFrame ---@return ScratchFramePanes function M.open(frame) - local comment_lines = vim.split(frame.comment, "\n") - local wanted_context = #frame.context - local wanted_comment = math.max(#comment_lines, 5) - ---@return { width: integer, col: integer, row: integer, context: integer, comment: integer } local function layout() local lines = frame.lines or vim.o.lines local width = math.min(80, vim.o.columns - 4) - local context = math.min(wanted_context, math.floor(lines * 0.4)) - local comment = math.min(wanted_comment, math.floor(lines * 0.3)) + local context = math.min(#frame.context, math.floor(lines * 0.4)) + local comment = math.min(math.max(#vim.split(frame.comment, "\n"), 5), math.floor(lines * 0.3)) return { width = width, col = math.floor((vim.o.columns - width) / 2), @@ -92,7 +91,7 @@ function M.open(frame) vim.tbl_extend("error", context_place, { border = border, style = "minimal" }) ) - local comment_buf = frame_buffer(comment_lines, "markdown") + local comment_buf = frame_buffer(vim.split(frame.comment, "\n"), "markdown") if frame.comment_name then vim.api.nvim_buf_set_name( comment_buf, @@ -164,13 +163,37 @@ function M.open(frame) end, }) - return { + local panes = { context_win = context_win, context_buf = context_buf, comment_win = comment_win, comment_buf = comment_buf, backdrop_win = dim, } + -- A preview can change entries or become an editor without replacing the + -- floating windows. Keep the same placement rule for either state. + function panes.update(spec, preserve_comment) + frame = spec + if preserve_comment then + spec.comment = table.concat(vim.api.nvim_buf_get_lines(comment_buf, 0, -1, false), "\n") + end + vim.bo[context_buf].modifiable = true + vim.api.nvim_buf_set_lines(context_buf, 0, -1, false, spec.context) + vim.bo[context_buf].modifiable = false + vim.bo[context_buf].modified = false + vim.bo[context_buf].filetype = spec.filetype + if not preserve_comment then + vim.bo[comment_buf].modifiable = true + vim.api.nvim_buf_set_lines(comment_buf, 0, -1, false, vim.split(spec.comment, "\n")) + vim.bo[comment_buf].modified = false + vim.bo[comment_buf].modifiable = false + end + backdrop.resize(dim, frame.lines) + local context_next, comment_next = placement(layout()) + vim.api.nvim_win_set_config(context_win, context_next) + vim.api.nvim_win_set_config(comment_win, comment_next) + end + return panes end return M diff --git a/lua/scratch_comments/ui/preview.lua b/lua/scratch_comments/ui/preview.lua index 4481b1a..b482371 100644 --- a/lua/scratch_comments/ui/preview.lua +++ b/lua/scratch_comments/ui/preview.lua @@ -1,36 +1,72 @@ local frame = require("scratch_comments.ui.frame") +local backdrop = require("scratch_comments.ui.backdrop") local M = {} ----@type integer[] the panes of the open preview -local current = {} +---@type ScratchFramePanes? the panes of the open preview +local current ---@type ScratchFrame? what the open preview shows local showing +---@type string? ID displayed in the passive card +local showing_id + function M.close() - for _, win in ipairs(current) do - pcall(vim.api.nvim_win_close, win, true) + local panes = current + current, showing, showing_id = nil, nil, nil + pcall(vim.api.nvim_del_augroup_by_name, "scratch_comments_preview") + if panes then + pcall(vim.api.nvim_win_close, panes.comment_win, true) + pcall(vim.api.nvim_win_close, panes.context_win, true) + backdrop.close(panes.backdrop_win) + end +end + +---@return boolean +function M.is_open() + return current ~= nil and vim.api.nvim_win_is_valid(current.comment_win) +end + +---@return string? +function M.current_id() + return M.is_open() and showing_id or nil +end + +-- Transfer the visible card to the editor; BufLeave must no longer dismiss it. +---@return ScratchFramePanes? +function M.take() + local panes = current + if panes and showing then + panes.lines = showing.lines + panes.backdrop = showing.backdrop + end + current, showing, showing_id = nil, nil, nil + pcall(vim.api.nvim_del_augroup_by_name, "scratch_comments_preview") + if panes and vim.api.nvim_win_is_valid(panes.comment_win) then + return panes end - current = {} - showing = nil end -- A card beside whatever you are browsing: no focus, no editing. ---@param spec ScratchFrame -function M.show(spec) +---@param id? string displayed comment ID +function M.show(spec, id) local browsing = vim.api.nvim_get_current_win() - M.close() spec.enter = false -- Fit above the window being browsed, so the card does not cover it, and -- dim only that area so the window stays readable. local above = vim.fn.win_screenpos(browsing)[1] - 1 spec.lines = above >= 10 and above or nil spec.backdrop = spec.lines ~= nil - local panes = frame.open(spec) - vim.bo[panes.comment_buf].modifiable = false - current = { panes.comment_win, panes.context_win, panes.backdrop_win } - showing = spec + if current and M.is_open() then + current.update(spec) + else + M.close() + current = frame.open(spec) + vim.bo[current.comment_buf].modifiable = false + end + showing, showing_id = spec, id -- The window being browsed moves when the editor resizes, so measure again. local group = vim.api.nvim_create_augroup("scratch_comments_preview", { clear = true }) @@ -41,7 +77,7 @@ function M.show(spec) return true end vim.api.nvim_win_call(browsing, function() - M.show(showing) + M.show(showing, showing_id) end) end, }) diff --git a/lua/scratch_comments/ui/window.lua b/lua/scratch_comments/ui/window.lua index 9676eab..1c0cba3 100644 --- a/lua/scratch_comments/ui/window.lua +++ b/lua/scratch_comments/ui/window.lua @@ -23,15 +23,19 @@ local mode_names = { ---@field comment_win? integer ---@field on_close? fun() ---@field on_saved? fun() ----@field from_list? boolean +---@field update? fun(spec: ScratchFrame) ---@type ScratchCommentWindow? local current ---@param spec ScratchFrame ---@param record ScratchCommentWindow -function M.open(spec, record) - preview.close() +---@param panes? ScratchFramePanes existing preview panes to focus in place +function M.open(spec, record, panes) + local promoted = panes ~= nil + if not panes then + preview.close() + end spec.on_close = function() if current and current.comment_win == record.comment_win then current = nil @@ -45,8 +49,22 @@ function M.open(spec, record) or "comment" local source_name = vim.fn.fnamemodify(source_buffer_name, ":t") spec.comment_name = source_name .. ":" .. record.line .. " [comment]" - local panes = frame.open(spec) + if panes then + spec.lines = panes.lines + spec.backdrop = panes.backdrop + panes.update(spec) + else + panes = frame.open(spec) + end local comment_win, comment_buf = panes.comment_win, panes.comment_buf + if promoted then + vim.api.nvim_buf_set_name( + comment_buf, + "scratch-comments://" .. comment_buf .. "/" .. spec.comment_name + ) + vim.bo[comment_buf].modifiable = true + vim.api.nvim_set_current_win(comment_win) + end record.comment_win = comment_win current = record @@ -61,6 +79,15 @@ function M.open(spec, record) vim.cmd.redraw() end end + record.update = function(next_spec) + next_spec.on_close = spec.on_close + next_spec.on_save = spec.on_save + next_spec.lines = spec.lines + next_spec.backdrop = spec.backdrop + panes.update(next_spec, true) + spec = next_spec + show_mode() + end show_mode() vim.api.nvim_create_autocmd("ModeChanged", { buffer = comment_buf, callback = show_mode }) diff --git a/plugin/scratch-comments.lua b/plugin/scratch-comments.lua index f54ad05..cf4fb3f 100644 --- a/plugin/scratch-comments.lua +++ b/plugin/scratch-comments.lua @@ -13,17 +13,9 @@ command("Comment", function(ctx) require("scratch_comments.actions").comment(ctx.line1, ctx.line2, ctx.range == 2) end, { range = true, desc = "Comment on the current line or range" }) -command("CommentShow", function() - scratch().show() -end, { desc = "Show the comments at the cursor" }) - -command("CommentNext", function() - scratch().next() -end, { desc = "Go to the next comment" }) - -command("CommentPrev", function() - scratch().prev() -end, { desc = "Go to the previous comment" }) +command("CommentDelete", function() + scratch().delete() +end, { desc = "Delete the comment in the open card" }) command("CommentList", function(ctx) scratch().list(ctx.args ~= "" and ctx.args or nil) diff --git a/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua index 8f513bd..3e7966c 100644 --- a/tests/features/anchoring_spec.lua +++ b/tests/features/anchoring_spec.lua @@ -129,7 +129,7 @@ describe("an orphan", function() it("can be deleted from the comment list", function() vim.cmd.CommentList() - vim.cmd("normal d") + vim.cmd.CommentDelete() expect.equality(#views.orphans(), 0) end) end) @@ -236,7 +236,7 @@ describe("a file's comments", function() expect.equality(#views.orphans(), 1) expect.equality(#views.anchored(), 0) vim.cmd.CommentList() - vim.cmd("normal d") + vim.cmd.CommentDelete() expect.equality(#store.all(), 0) end) diff --git a/tests/features/commenting_spec.lua b/tests/features/commenting_spec.lua index 6c500ed..66bddd3 100644 --- a/tests/features/commenting_spec.lua +++ b/tests/features/commenting_spec.lua @@ -19,14 +19,16 @@ describe(":Comment", function() expect.equality(views.anchored()[1].comment, "first comment") end) - it("on the same line edits that comment instead of adding one", function() + it("does not duplicate a comment on the same range", function() vim.cmd("Comment") helpers.write("first comment") vim.cmd("Comment") - expect.equality(helpers.text(), "first comment") - helpers.write("edited comment") expect.equality(#store.all(), 1) - expect.equality(views.anchored()[1].comment, "edited comment") + expect.equality(#helpers.floats(), 0) + expect.equality( + helpers.notified()[#helpers.notified()], + "This range already has a comment; edit it from :CommentList" + ) end) it("on a range adds a separate comment over those lines", function() diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index 7c8eeba..5a0f17f 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -108,7 +108,7 @@ describe("the comment list manager", function() it("edits a comment in the same card, then refreshes the list", function() vim.cmd("CommentList") local id = vim.fn.getqflist()[1].user_data.scratch_comments_id - vim.cmd("normal e") + vim.cmd("normal \r") expect.equality(helpers.text(), "on a") expect.equality(vim.bo.modifiable, true) vim.api.nvim_buf_set_lines(0, 0, -1, false, { "revised" }) @@ -116,7 +116,7 @@ describe("the comment list manager", function() expect.equality(vim.fn.getqflist()[1].text, "revised") vim.cmd.quit() vim.wait(0) - expect.equality(vim.bo.buftype, "quickfix") + expect.equality(vim.api.nvim_get_current_buf(), source) expect.equality(vim.fn.getqflist()[1].text, "revised") expect.equality(vim.fn.getqflist()[1].user_data.scratch_comments_id, id) vim.cmd.cclose() @@ -124,26 +124,42 @@ describe("the comment list manager", function() it("doesn't reopen a list the user closed while editing", function() vim.cmd.CommentList() - vim.cmd("normal e") + vim.cmd("normal \r") vim.cmd.cclose() vim.cmd.quit() vim.wait(0) expect.equality(vim.fn.getqflist({ winid = 0 }).winid, 0) end) - it("doesn't navigate a file from a manager-opened card", function() + it("leaves the source cursor alone while browsing", function() + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + local source_win = vim.api.nvim_get_current_win() vim.cmd.CommentList() - vim.cmd("normal e") - vim.cmd.cclose() - vim.cmd.CommentNext() - expect.equality(helpers.text(), "on a") - expect.equality(#helpers.floats(), 2) + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) + expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 1) + vim.cmd("normal \r") + expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 3) vim.cmd.quit() end) + it("doesn't return an orphan editor to another plugin's quickfix list", function() + vim.cmd("1d") + helpers.text_changed() + local file_win = vim.api.nvim_get_current_win() + vim.cmd.CommentList() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.cmd("normal \r") + vim.fn.setqflist({}, " ", { items = { { filename = "other.lua", lnum = 1, text = "other" } } }) + vim.cmd.quit() + vim.api.nvim_set_current_win(file_win) + vim.wait(0) + expect.equality(vim.api.nvim_get_current_win(), file_win) + end) + it("keeps focus on an export replacing a manager-opened card", function() vim.cmd.CommentList() - vim.cmd("normal e") + vim.cmd("normal \r") vim.cmd("CommentExport!") vim.wait(0) expect.equality(vim.bo.buftype, "nofile") @@ -154,7 +170,7 @@ describe("the comment list manager", function() it("edits a comment whose source was wiped", function() vim.cmd("bwipeout! " .. source) vim.cmd.CommentList() - vim.cmd("normal e") + vim.cmd("normal \r") expect.equality(helpers.text(), "on a") vim.api.nvim_buf_set_lines(0, 0, -1, false, { "revised parked" }) vim.cmd("wq") @@ -164,13 +180,29 @@ describe("the comment list manager", function() vim.cmd.cclose() end) + it("opens an orphan for editing without a guessed jump", function() + vim.cmd("1d") + helpers.text_changed() + vim.cmd.CommentList() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) + local qf_win = vim.api.nvim_get_current_win() + vim.cmd("normal \r") + expect.equality(helpers.text(), "on a") + vim.cmd.quit() + vim.wait(0) + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(vim.bo.buftype, "quickfix") + end) + it("deletes an orphan by saving its card empty", function() vim.api.nvim_set_current_buf(source) vim.cmd("1d") helpers.text_changed() vim.cmd.CommentList() vim.api.nvim_win_set_cursor(0, { 2, 0 }) - vim.cmd("normal e") + vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) + vim.cmd("normal \r") expect.equality(helpers.text(), "on a") vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) vim.cmd.write() @@ -181,10 +213,10 @@ describe("the comment list manager", function() vim.cmd.cclose() end) - it("deletes the selected comment and keeps the filtered list", function() + it("deletes the displayed comment and keeps the filtered list", function() vim.cmd("CommentList export-fixture") local removed = vim.fn.getqflist()[1].user_data.scratch_comments_id - vim.cmd("normal d") + vim.cmd.CommentDelete() expect.equality(#store.all(), 1) expect.no_equality(store.all()[1].id, removed) expect.equality(#vim.fn.getqflist(), 1) @@ -206,7 +238,7 @@ describe("the comment list manager", function() vim.cmd("bwipeout! " .. source) expect.equality(#store.all(), 2) vim.cmd.CommentList() - vim.cmd("normal d") + vim.cmd.CommentDelete() expect.equality(#store.all(), 1) expect.equality(store.all()[1].comment, "on c") vim.cmd.cclose() @@ -223,7 +255,8 @@ describe("the comment list manager", function() return item.text:find("[orphaned]", 1, true) ~= nil end) vim.api.nvim_win_set_cursor(0, { index, 0 }) - vim.cmd("normal d") + vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) + vim.cmd.CommentDelete() expect.equality(#store.all(), 1) expect.equality(store.all()[1].comment, "on c") vim.cmd.cclose() @@ -232,7 +265,7 @@ describe("the comment list manager", function() it("does not delete another plugin's quickfix entry", function() vim.cmd.CommentList() vim.fn.setqflist({}, " ", { items = { { filename = "other.lua", lnum = 1, text = "other" } } }) - vim.cmd("normal d") + vim.cmd.CommentDelete() expect.equality(#store.all(), 2) vim.cmd.cclose() end) @@ -264,18 +297,84 @@ describe("the comment list manager", function() end vim.api.nvim_win_set_cursor(0, { index, 0 }) vim.cmd("normal \r") - expect.equality(pending, vim.api.nvim_get_current_buf()) + local source_win = vim.fn.bufwinid(url) + expect.equality(vim.api.nvim_win_get_buf(source_win), pending) expect.equality(store.all()[#store.all()].state, "parked") vim.api.nvim_buf_set_lines(pending, 0, -1, false, { "first", "second" }) vim.wait(0) expect.equality(store.all()[#store.all()].state, nil) - expect.equality(vim.api.nvim_win_get_cursor(0)[1], 2) + expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 2) + vim.cmd.quit() + vim.wait(0) vim.api.nvim_win_set_cursor(0, { 1, 0 }) vim.api.nvim_buf_set_lines(pending, 0, 1, false, { "updated" }) vim.wait(0) expect.equality(vim.api.nvim_win_get_cursor(0)[1], 1) end) + it("doesn't move the source cursor after leaving an asynchronous jump", function() + local url = "review-test:///late-cursor.lua" + local pending + vim.api.nvim_create_autocmd("BufReadCmd", { + pattern = "review-test:///*", + once = true, + callback = function(args) + pending = args.buf + end, + }) + vim.cmd.enew() + vim.api.nvim_buf_set_name(0, url) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "first", "second" }) + vim.bo.bufhidden = "wipe" + vim.cmd("2Comment") + helpers.write("late") + vim.cmd("enew!") + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + local index = vim.iter(vim.fn.getqflist()):enumerate():find(function(_, item) + return item.user_data.scratch_comments_id == store.all()[#store.all()].id + end) + vim.api.nvim_win_set_cursor(0, { index, 0 }) + vim.cmd("normal \r") + local editor_win = vim.api.nvim_get_current_win() + local source_win = vim.fn.bufwinid(url) + vim.api.nvim_win_set_cursor(source_win, { 1, 0 }) + vim.api.nvim_set_current_win(qf_win) + vim.api.nvim_set_current_win(editor_win) + vim.api.nvim_buf_set_lines(pending, 0, -1, false, { "first", "second" }) + vim.wait(0) + expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 1) + end) + + it("shows an orphan rather than stale source lines after a mismatched reload", function() + vim.cmd.edit("tests/fixtures/lifecycle.txt") + vim.api.nvim_buf_set_lines(0, 0, 1, false, { "unsaved source" }) + vim.cmd.Comment() + helpers.write("comment on unsaved source") + local wanted = store.all()[#store.all()].id + vim.cmd("bdelete!") + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + local index = vim.iter(vim.fn.getqflist()):enumerate():find(function(_, item) + return item.user_data.scratch_comments_id == wanted + end) + vim.api.nvim_win_set_cursor(0, { index, 0 }) + vim.cmd("normal \r") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved edit" }) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, "inactive") + local context_win = helpers.panes() + expect.equality( + helpers.text(vim.api.nvim_win_get_buf(context_win)), + "The lines this comment was on are gone." + ) + expect.equality(helpers.text(), "unsaved edit") + expect.equality(vim.bo.modified, true) + vim.cmd("q!") + vim.wait(0) + expect.equality(vim.api.nvim_get_current_win(), qf_win) + end) + it("jumps to a parked file and restores its comment", function() vim.cmd("edit! tests/fixtures/lifecycle.txt") vim.cmd.Comment() @@ -291,6 +390,7 @@ describe("the comment list manager", function() end vim.api.nvim_win_set_cursor(0, { index, 0 }) vim.cmd("normal \r") + vim.cmd.quit() vim.wait(0) expect.equality(vim.api.nvim_buf_get_name(0), store.all()[#store.all()].source_name) expect.equality(store.all()[#store.all()].state, nil) @@ -351,7 +451,8 @@ end) describe("one card at a time", function() it(":CommentList replaces an open comment window", function() - vim.cmd("CommentShow") + vim.cmd.CommentList() + vim.cmd("normal \r") expect.equality(#helpers.floats(), 2) vim.cmd("CommentList") expect.equality(#helpers.floats(), 2) @@ -359,7 +460,8 @@ describe("one card at a time", function() end) it(":CommentList keeps an unsaved comment, and says so", function() - vim.cmd("CommentShow") + vim.cmd.CommentList() + vim.cmd("normal \r") vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) vim.cmd("CommentList") expect.equality(helpers.text(), "unsaved") @@ -367,7 +469,8 @@ describe("one card at a time", function() end) it(":CommentExport! closes the comment window first", function() - vim.cmd("CommentShow") + vim.cmd.CommentList() + vim.cmd("normal \r") vim.cmd("CommentExport!") expect.equality(#helpers.every_float(), 0) expect.equality(vim.bo.buftype, "nofile") @@ -396,15 +499,61 @@ describe("the card beside the list", function() vim.api.nvim_exec_autocmds("CursorMoved", { buffer = vim.api.nvim_get_current_buf() }) end - it("shows the comment the cursor is on, and follows it", function() + it("shows the comment the cursor is on, reusing the card as it follows", function() vim.cmd("CommentList") + local panes = helpers.floats() expect.equality(card(), "on a") move_to(2) expect.equality(card(), "on c") + expect.equality(helpers.floats(), panes) move_to(1) expect.equality(card(), "on a") end) + it("focuses the same card in the same place on ", function() + vim.cmd.CommentList() + local panes = helpers.floats() + local comment_win = vim.iter(panes):find(function(win) + return vim.bo[vim.api.nvim_win_get_buf(win)].filetype == "markdown" + end) + local before = vim.api.nvim_win_get_config(comment_win) + vim.cmd("normal \r") + local after = vim.api.nvim_win_get_config(comment_win) + expect.equality(helpers.floats(), panes) + expect.equality({ after.row, after.col, after.width }, { before.row, before.col, before.width }) + expect.equality(vim.api.nvim_get_current_win(), comment_win) + expect.equality(vim.bo.modifiable, true) + vim.cmd.quit() + vim.wait(0) + expect.equality(vim.api.nvim_get_current_buf(), source) + end) + + it("dismisses a preview on until the row changes", function() + vim.cmd.CommentList() + vim.cmd([[execute "normal \"]]) + expect.equality(#helpers.floats(), 0) + vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) + expect.equality(#helpers.floats(), 0) + move_to(2) + expect.equality(card(), "on c") + end) + + it("closes a clean editor on from the list, but preserves unsaved edits", function() + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + vim.cmd("normal \r") + local comment_buf = vim.api.nvim_get_current_buf() + vim.api.nvim_set_current_win(qf_win) + vim.api.nvim_buf_set_lines(comment_buf, 0, -1, false, { "unsaved" }) + vim.cmd([[execute "normal \"]]) + expect.equality(#helpers.floats(), 2) + expect.equality(helpers.notified()[#helpers.notified()], "Save with :w, or discard with :q!") + vim.bo[comment_buf].modified = false + vim.cmd([[execute "normal \"]]) + expect.equality(#helpers.floats(), 0) + expect.equality(vim.bo.buftype, "quickfix") + end) + it("dims what is above it, leaving the list readable", function() vim.cmd("CommentList") local dim = vim.tbl_filter(function(win) diff --git a/tests/features/navigation_spec.lua b/tests/features/navigation_spec.lua deleted file mode 100644 index 8c7fa4c..0000000 --- a/tests/features/navigation_spec.lua +++ /dev/null @@ -1,136 +0,0 @@ -local helpers = require("helpers") -local expect = MiniTest.expect - -local file_win - -local function cursor_line() - return vim.api.nvim_win_get_cursor(file_win)[1] -end - ----@param command string ----@return string lines the cursor visited, four steps -local function walk(command) - local lines = {} - for _ = 1, 4 do - vim.cmd(command) - table.insert(lines, cursor_line()) - end - return table.concat(lines, ",") -end - -before_each(function() - helpers.reset() - helpers.buffer("notes.md", { "1", "2", "3", "4", "5", "6", "7", "8" }) - file_win = vim.api.nvim_get_current_win() - vim.cmd("2Comment") - helpers.write("on two") - vim.cmd("4,5Comment") - helpers.write("on four and five") - vim.cmd("4Comment") - helpers.write("also on four") - vim.cmd("7Comment") - helpers.write("on seven") - vim.api.nvim_win_set_cursor(0, { 1, 0 }) -end) - -describe("without a comment open", function() - it(":CommentNext visits each start line once, then wraps", function() - expect.equality(walk("CommentNext"), "2,4,7,2") - end) - - it(":CommentPrev goes back, then wraps", function() - expect.equality(walk("CommentPrev"), "7,4,2,7") - end) - - it("jumps can be undone with ", function() - vim.cmd("CommentNext") - vim.cmd([[execute "normal! \"]]) - expect.equality(cursor_line(), 1) - end) - - it("only moves the cursor", function() - vim.cmd("CommentNext") - expect.equality(#helpers.floats(), 0) - end) -end) - -describe("with a comment open", function() - before_each(function() - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - vim.cmd("CommentShow") - end) - - it("counts the file's comments", function() - expect.equality(helpers.title(0), " comment (1 of 4) [NORMAL] ") - end) - - it(":CommentNext shows each comment in turn, then wraps, the cursor following", function() - local shown, lines = {}, {} - for _ = 1, 4 do - vim.cmd("CommentNext") - table.insert(shown, helpers.text()) - table.insert(lines, cursor_line()) - end - expect.equality(table.concat(shown, ","), "also on four,on four and five,on seven,on two") - expect.equality(table.concat(lines, ","), "4,4,7,2") - expect.equality(#helpers.floats(), 2) - expect.equality(helpers.title(0), " comment (1 of 4) [NORMAL] ") - end) - - it(":CommentPrev goes back", function() - vim.cmd("CommentPrev") - expect.equality(helpers.text(), "on seven") - end) - - it("won't move while the comment has unsaved changes", function() - vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) - vim.cmd("CommentNext") - expect.equality(helpers.text(), "unsaved") - end) -end) - -describe("from a new comment", function() - it("moves to the next comment after its line", function() - vim.api.nvim_win_set_cursor(0, { 3, 0 }) - vim.cmd("Comment") - vim.cmd("stopinsert") - vim.cmd("CommentNext") - expect.equality(helpers.text(), "also on four") - end) -end) - -describe("with comments in another file", function() - it("stays in the current file", function() - local other = helpers.buffer("other.md", { "1", "2", "3" }) - vim.cmd("3Comment") - helpers.write("in the other file") - vim.api.nvim_win_set_cursor(0, { 1, 0 }) - vim.cmd("CommentNext") - vim.cmd("CommentNext") - expect.equality(vim.api.nvim_get_current_buf(), other) - expect.equality(vim.api.nvim_win_get_cursor(0)[1], 3) - end) -end) - -describe("when things change under an open comment", function() - it("moves on from its line when its comment was deleted", function() - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - vim.cmd("CommentShow") - vim.api.nvim_set_current_win(file_win) - local comments = require("scratch_comments.comments") - local store = require("scratch_comments.model.store") - comments.delete(store.all()[1]) - vim.cmd("CommentNext") - expect.equality(helpers.text(), "also on four") - end) - - it("still cycles when the window it came from is closed", function() - vim.cmd("split") - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - local split = vim.api.nvim_get_current_win() - vim.cmd("CommentShow") - vim.api.nvim_win_close(split, true) - vim.cmd("CommentNext") - expect.equality(helpers.text(), "also on four") - end) -end) diff --git a/tests/features/plugin_spec.lua b/tests/features/plugin_spec.lua index bf6cf69..1fae17e 100644 --- a/tests/features/plugin_spec.lua +++ b/tests/features/plugin_spec.lua @@ -21,11 +21,14 @@ end describe("the plugin", function() it("defines its commands without setup()", function() - expect.equality(fresh("io.write(vim.fn.exists(':CommentShow'))"), "2") + expect.equality(fresh("io.write(vim.fn.exists(':CommentList'))"), "2") end) - it("has no :CommentDelete command", function() - expect.equality(fresh("io.write(vim.fn.exists(':CommentDelete'))"), "0") + it("defines :CommentDelete but not the old navigation commands", function() + expect.equality(fresh("io.write(vim.fn.exists(':CommentDelete'))"), "2") + for _, command in ipairs({ "CommentShow", "CommentNext", "CommentPrev" }) do + expect.equality(fresh("io.write(vim.fn.exists(':" .. command .. "'))"), "0") + end end) it("loads nothing until a command runs", function() diff --git a/tests/features/show_delete_spec.lua b/tests/features/show_delete_spec.lua index a88c548..77743df 100644 --- a/tests/features/show_delete_spec.lua +++ b/tests/features/show_delete_spec.lua @@ -10,76 +10,72 @@ before_each(function() helpers.write("on one") vim.cmd("1,3Comment") helpers.write("on one to three") - vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.cmd.CommentList() end) -describe(":CommentShow", function() - it("asks which comment when several cover the line", function() - helpers.pick(2) - vim.cmd("CommentShow") - expect.equality(#helpers.offered, 2) - expect.equality(helpers.text(), "on one to three") +describe(":CommentDelete", function() + it("deletes the displayed card while the list has focus", function() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) + vim.cmd.CommentDelete() + expect.equality(#store.all(), 1) + expect.equality(views.anchored()[1].comment, "on one") end) - it("names each comment by its location and first line", function() - helpers.pick(0) - vim.cmd("CommentShow") - expect.equality(helpers.labels, { "line 1: on one", "lines 1–3: on one to three" }) + it("deletes the card that is visible, not a row changed without a redraw", function() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.cmd.CommentDelete() + expect.equality(#store.all(), 1) + expect.equality(store.all()[1].comment, "on one to three") end) - it("saves edits to the comment it shows", function() - helpers.pick(2) - vim.cmd("CommentShow") - helpers.write("picked the range") + it("deletes the focused card by its ID", function() + vim.cmd("normal \r") + vim.cmd.CommentDelete() + vim.wait(0) + expect.equality(#store.all(), 1) + expect.equality(views.anchored()[1].comment, "on one to three") + end) + + it("keeps quickfix focus when invoked there while an editor is open", function() + local qf_win = vim.api.nvim_get_current_win() + vim.cmd("normal \r") + vim.api.nvim_set_current_win(qf_win) + vim.cmd.CommentDelete() + vim.wait(0) + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(#store.all(), 1) + end) + + it("does nothing without an open card", function() + vim.cmd([[execute "normal \"]]) + vim.cmd.CommentDelete() expect.equality(#store.all(), 2) - expect.equality(views.anchored()[2].comment, "picked the range") + expect.equality(helpers.notified()[#helpers.notified()], "No comment card open") end) - it("opens nothing when the picker is cancelled", function() - helpers.pick(0) - vim.cmd("CommentShow") - expect.equality(#helpers.floats(), 0) - end) - - it("closes with :q", function() - helpers.pick(2) - vim.cmd("CommentShow") - vim.cmd("quit") - expect.equality(#helpers.floats(), 0) - end) -end) - -describe("a comment that is gone", function() - it("keeps your text and says nothing was saved", function() - helpers.pick(1) - vim.cmd("CommentShow") - vim.cmd("CommentClear") - vim.api.nvim_buf_set_lines(0, 0, -1, false, { "typed after it was cleared" }) - vim.cmd("write") - expect.equality(vim.bo.modified, true) - expect.equality(#helpers.floats(), 2) - expect.equality( - helpers.notified()[#helpers.notified()], - "This comment is gone; nothing was saved" - ) + it("protects unsaved changes", function() + vim.cmd("normal \r") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) + vim.cmd.CommentDelete() + expect.equality(#store.all(), 2) + expect.equality(helpers.notified()[#helpers.notified()], "Save or discard the comment first") end) end) describe("saving an empty comment", function() it("deletes the edited comment and closes its card", function() - helpers.pick(1) - vim.cmd("CommentShow") + vim.cmd("normal \r") vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) vim.cmd.write() vim.wait(0) expect.equality(#store.all(), 1) expect.equality(views.anchored()[1].comment, "on one to three") - expect.equality(#helpers.floats(), 0) + expect.equality(#vim.fn.getqflist(), 1) end) it("does not delete it when empty changes are discarded", function() - helpers.pick(1) - vim.cmd("CommentShow") + vim.cmd("normal \r") vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) vim.cmd("q!") expect.equality(#store.all(), 2) @@ -88,9 +84,10 @@ end) describe(":CommentClear", function() it("removes every comment and its marks", function() + local source = assert(views.anchored()[1].bufnr) vim.cmd("CommentClear") local namespace = vim.api.nvim_get_namespaces().scratch_comments expect.equality(#store.all(), 0) - expect.equality(#vim.api.nvim_buf_get_extmarks(0, namespace, 0, -1, {}), 0) + expect.equality(#vim.api.nvim_buf_get_extmarks(source, namespace, 0, -1, {}), 0) end) end) diff --git a/tests/features/spans_spec.lua b/tests/features/spans_spec.lua index 2eb3ca8..4844e34 100644 --- a/tests/features/spans_spec.lua +++ b/tests/features/spans_spec.lua @@ -41,9 +41,12 @@ describe("a charwise selection", function() vim.cmd("Comment") helpers.write("on the line") vim.cmd("1Comment") - expect.equality(helpers.text(), "on the line") - vim.cmd("quit") expect.equality(#views.anchored(), 2) + vim.cmd.CommentList() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.cmd("normal \r") + expect.equality(helpers.text(), "on the line") + vim.cmd.quit() end) end) diff --git a/tests/features/window_spec.lua b/tests/features/window_spec.lua index e1b7f35..26b4b17 100644 --- a/tests/features/window_spec.lua +++ b/tests/features/window_spec.lua @@ -11,7 +11,8 @@ before_each(function() vim.cmd("1,3Comment") helpers.write("on one to three") vim.api.nvim_win_set_cursor(0, { 1, 0 }) - vim.cmd("CommentShow") + vim.cmd.CommentList() + vim.cmd("normal \r") end) after_each(function() @@ -117,7 +118,11 @@ describe("the comment window", function() end, helpers.every_float()) expect.equality(#dim, 1) local config = vim.api.nvim_win_get_config(dim[1]) - expect.equality({ config.width, config.height }, { vim.o.columns, vim.o.lines }) + local qf_win = vim.fn.getqflist({ winid = 0 }).winid + expect.equality( + { config.width, config.height }, + { vim.o.columns, vim.fn.win_screenpos(qf_win)[1] - 1 } + ) expect.equality(config.zindex < vim.api.nvim_win_get_config(0).zindex, true) expect.equality(vim.wo[dim[1]].winblend, 60) vim.cmd("quit") @@ -130,7 +135,8 @@ describe("the comment window", function() end vim.cmd("quit") local baseline = handlers() - vim.cmd("CommentShow") + vim.cmd.CommentList() + vim.cmd("normal \r") expect.equality(handlers() > baseline, true) vim.cmd("quit") expect.equality(handlers(), baseline) @@ -156,13 +162,15 @@ describe("the border", function() it("is rounded when 'winborder' is not set", function() vim.o.winborder = "" - vim.cmd("CommentShow") + vim.cmd.CommentList() + vim.cmd("normal \r") expect.equality(vim.api.nvim_win_get_config(0).border[1], "╭") end) it("follows 'winborder' when it is set", function() vim.o.winborder = "single" - vim.cmd("CommentShow") + vim.cmd.CommentList() + vim.cmd("normal \r") expect.equality(vim.api.nvim_win_get_config(0).border[1], "┌") end) end) -- 2.54.0 From 9e2cd57d8fb64d28604abae5d55c8f470315043b Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 30 Sep 2026 05:20:13 -0700 Subject: [PATCH 09/13] Open existing comments from their exact range --- README.md | 4 ++-- doc/scratch-comments.txt | 7 ++++--- lua/scratch_comments/actions.lua | 2 +- tests/features/commenting_spec.lua | 11 +++++------ tests/features/spans_spec.lua | 15 +++++++++++---- 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8074c13..21842ce 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ at the top and your comment below. a new comment opens in insert mode, and the title shows the mode you're in. write as many lines as you like, then `:wq` to save. `:q` or `` closes it if you haven't changed anything, and `:q!` throws your changes away. `w` moves between the two parts, or scroll them with the -mouse. +mouse. commenting on an exact range again opens its comment for editing. commented lines get a mark in the sign column: `│` for one line, and `╭` `│` `╰` down a range. browse your comments, then copy them all: @@ -77,7 +77,7 @@ it, pipe it to a command, or edit it: | Command | Does | | --- | --- | -| `:Comment` | Comment on the current line or command range | +| `:Comment` | Add a comment on the current line or range, or edit one already on that exact range | | `:CommentList [filter]` | Put comments in the quickfix list and open it, fuzzy matching `filter` | | `:CommentDelete` | Delete the comment in the open card | | `:CommentExport[!] [format]` | Copy all comments to the clipboard as `markdown` (default) or `json`. `!` opens them in a scratch buffer | diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index b3c6a16..9ab3a29 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -34,7 +34,8 @@ Requires Neovim 0.12 or newer. gives ":'<,'>Comment". A characterwise selection (|v|) comments on just the selected text. A linewise one (|V|) comments on whole lines. If that exact text - already has a comment, use |:CommentList| to edit it. + already has a comment, open it for editing instead of + adding a duplicate. A window opens in the middle of the screen: the lines you are commenting on at the top, your comment below. @@ -90,8 +91,8 @@ Requires Neovim 0.12 or newer. A comment belongs to a file and a range of lines, or to the exact text you selected within them. -- Commenting on the same lines again does not add a duplicate; edit it from - |:CommentList|. +- Commenting on the same lines again opens that comment for editing instead + of adding a duplicate. - Commenting on different lines adds a new comment, even if the lines overlap another comment. A comment on lines 10–30 and one on line 14 are separate. diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua index 6d224a0..448f8e2 100644 --- a/lua/scratch_comments/actions.lua +++ b/lua/scratch_comments/actions.lua @@ -147,7 +147,7 @@ function M.comment(start_line, end_line, use_selection) local existing = find_anchor(bufnr, where.start_line, where.end_line, where.start_col, where.end_col) if existing then - notify.info("This range already has a comment; edit it from :CommentList") + M.show(existing, vim.api.nvim_get_current_win()) return end diff --git a/tests/features/commenting_spec.lua b/tests/features/commenting_spec.lua index 66bddd3..50ea0f0 100644 --- a/tests/features/commenting_spec.lua +++ b/tests/features/commenting_spec.lua @@ -19,16 +19,15 @@ describe(":Comment", function() expect.equality(views.anchored()[1].comment, "first comment") end) - it("does not duplicate a comment on the same range", function() + it("opens an existing comment on the exact range for editing", function() vim.cmd("Comment") helpers.write("first comment") vim.cmd("Comment") + expect.equality(helpers.text(), "first comment") + expect.equality(vim.bo.modifiable, true) + helpers.write("edited comment") expect.equality(#store.all(), 1) - expect.equality(#helpers.floats(), 0) - expect.equality( - helpers.notified()[#helpers.notified()], - "This range already has a comment; edit it from :CommentList" - ) + expect.equality(views.anchored()[1].comment, "edited comment") end) it("on a range adds a separate comment over those lines", function() diff --git a/tests/features/spans_spec.lua b/tests/features/spans_spec.lua index 4844e34..b186a99 100644 --- a/tests/features/spans_spec.lua +++ b/tests/features/spans_spec.lua @@ -36,17 +36,24 @@ describe("a charwise selection", function() expect.equality(quick.end_col, 9) end) + it("opens an existing comment on the exact selection for editing", function() + helpers.write("on quick") + select("viw") + vim.cmd("'<,'>Comment") + expect.equality(helpers.text(), "on quick") + helpers.write("edited quick") + expect.equality(#views.anchored(), 1) + expect.equality(views.anchored()[1].comment, "edited quick") + end) + it("is separate from a comment on the whole line", function() helpers.write("on quick") vim.cmd("Comment") helpers.write("on the line") vim.cmd("1Comment") - expect.equality(#views.anchored(), 2) - vim.cmd.CommentList() - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - vim.cmd("normal \r") expect.equality(helpers.text(), "on the line") vim.cmd.quit() + expect.equality(#views.anchored(), 2) end) end) -- 2.54.0 From b2b8abf8353b2ef452f75d907ffb247cefc5bba8 Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 30 Sep 2026 05:54:18 -0700 Subject: [PATCH 10/13] Open comment cards only on quickfix activation --- README.md | 15 ++-- doc/scratch-comments.txt | 16 ++-- lua/scratch_comments/actions.lua | 5 +- lua/scratch_comments/list.lua | 76 ++-------------- lua/scratch_comments/ui/frame.lua | 19 ++-- lua/scratch_comments/ui/preview.lua | 86 ------------------ lua/scratch_comments/ui/quickfix.lua | 41 +-------- lua/scratch_comments/ui/window.lua | 28 +----- tests/features/anchoring_spec.lua | 2 + tests/features/list_export_spec.lua | 125 ++++++++------------------- tests/features/show_delete_spec.lua | 19 ++-- tests/features/window_spec.lua | 6 +- 12 files changed, 82 insertions(+), 356 deletions(-) delete mode 100644 lua/scratch_comments/ui/preview.lua diff --git a/README.md b/README.md index 21842ce..9740da9 100644 --- a/README.md +++ b/README.md @@ -98,13 +98,12 @@ vim.keymap.set("n", "cx", "CommentExport", { desc = "Copy comme ## browsing `:CommentList` puts all your comments in the quickfix list. move through them -with `j`/`k` to preview a card without moving the source cursor. `` jumps -to the source when the comment has a location, and focuses the same card for -editing. `:w` saves; `:CommentDelete` deletes the comment shown in a card, -from either the card or the list. `` dismisses a clean card in either -place; unsaved edits need `:w` or `:q!`. an orphan has no line to jump to, so -its editor returns to the list. give `:CommentList` an argument to fuzzy match -comments by text, snippet or path: +with `j`/`k` without moving the source cursor or opening a card. `` jumps +to the source when the comment has a location, then opens its card for editing. +`:w` saves; `:CommentDelete` deletes the comment in an open card, even if you +focus the list again. `` closes a clean card; unsaved edits need `:w` or +`:q!`. an orphan has no line to jump to, so its editor returns to the list. +give `:CommentList` an argument to fuzzy match text, snippet or path: ```vim :CommentList typo @@ -138,7 +137,7 @@ while its card is open. ## colors `ScratchCommentSign` colors the signs (linked to `Todo` by default). -`ScratchCommentBackdrop` dims the editor behind comments and `:CommentList` +`ScratchCommentBackdrop` dims the editor behind an open comment card (black at 60% blend by default). set them in your colorscheme's highlight callback to follow light and dark diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index 9ab3a29..8a718f6 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -50,14 +50,14 @@ Requires Neovim 0.12 or newer. :CommentList [filter] Put every comment in the |quickfix| list and open it. With [filter], only the comments that fuzzy match it in their text, snippet or path (see |matchfuzzy()|). - Moving through the built-in list previews a card - without moving the source cursor. jumps to the - comment's source location and focuses that same card - for editing in normal mode. An orphan has no location; + Moving through the built-in list does not move the + source cursor or open a card. jumps to the + comment's source location and opens its card for + editing in normal mode. An orphan has no location; edits it without a jump. |:w| saves; saving it - empty deletes it. dismisses a clean card from + empty deletes it. closes a clean card from the list or the card; unsaved changes require |:w| or - |:q!|. The preview goes away with the list. + |:q!|. With no card open, does nothing. Other quickfix front-ends (trouble.nvim, nvim-bqf, Snacks.picker.qflist, :Telescope quickfix) can show the entries; and belong to the built-in @@ -135,8 +135,8 @@ The signs use ScratchCommentSign, which links to |hl-Todo|. To change it: >lua }) < *ScratchCommentBackdrop* -The editor dims behind the comment window, and behind the card beside -|:CommentList|, with ScratchCommentBackdrop over it at 'winblend' 60. It +The editor dims behind the comment window, with ScratchCommentBackdrop over +it at 'winblend' 60. It needs 'termguicolors'. For a different dim, or none: >lua vim.api.nvim_set_hl(0, "ScratchCommentBackdrop", { bg = "#000033" }) vim.api.nvim_set_hl(0, "ScratchCommentBackdrop", { bg = "NONE" }) diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua index 448f8e2..0d9b981 100644 --- a/lua/scratch_comments/actions.lua +++ b/lua/scratch_comments/actions.lua @@ -62,10 +62,9 @@ end ---@param view ScratchComment|ScratchCommentView ---@param source_win integer ----@param panes ScratchFramePanes? visible preview to focus ---@param on_close? fun() ---@param on_saved? fun() -function M.show(view, source_win, panes, on_close, on_saved) +function M.show(view, source_win, on_close, on_saved) local frame if view.start_line then frame = card.of(view --[[@as ScratchCommentView]]) @@ -83,7 +82,7 @@ function M.show(view, source_win, panes, on_close, on_saved) source_win = source_win, on_close = on_close, on_saved = on_saved, - }, panes) + }) end -- Opens an editor for a comment that does not exist yet; it is created when diff --git a/lua/scratch_comments/list.lua b/lua/scratch_comments/list.lua index c1bec5e..fa3dc7d 100644 --- a/lua/scratch_comments/list.lua +++ b/lua/scratch_comments/list.lua @@ -2,7 +2,6 @@ local actions = require("scratch_comments.actions") local card = require("scratch_comments.ui.card") local comments = require("scratch_comments.comments") local store = require("scratch_comments.model.store") -local preview = require("scratch_comments.ui.preview") local notify = require("scratch_comments.ui.notify") local quickfix = require("scratch_comments.ui.quickfix") local window = require("scratch_comments.ui.window") @@ -15,7 +14,6 @@ local list_bufnr local active_filter local pending_jump = {} local listening = {} -local dismissed_id local refresh ---@param comment ScratchComment|ScratchCommentView @@ -114,12 +112,6 @@ local function jump() notify.warn("This comment is gone") return end - -- Claim the visible panes before :cc triggers BufLeave on the quickfix list. - -- Enter changes focus and writeability, never the card's placement. - if preview.current_id() ~= id then - preview.show(entry.start_line and card.of(entry) or card.of_orphan(entry), id) - end - local panes = preview.take() local owner = qf_win local bufnr, win if entry.start_line then @@ -145,7 +137,7 @@ local function jump() vim.api.nvim_set_current_win(qf_win) end end - actions.show(entry, owner, panes, function() + actions.show(entry, owner, function() vim.schedule(function() if owner == qf_win and vim.fn.getqflist({ id = 0 }).id ~= expected_id then return @@ -261,7 +253,6 @@ refresh = function(id, index, expected_id) local items = quickfix.items(anchored, orphans) vim.fn.setqflist({}, "r", { title = "Comments", items = items }) if #items == 0 then - preview.close() vim.api.nvim_win_call(qf.winid, function() vim.cmd.cclose() end) @@ -278,12 +269,12 @@ refresh = function(id, index, expected_id) vim.api.nvim_win_set_cursor(qf.winid, { row, 0 }) end --- Delete only the comment displayed in a card, never one inferred from the --- cursor in an arbitrary file or an unrelated quickfix list. +-- Delete only the comment displayed in an open editor, never one inferred +-- from the cursor in a file or the selected quickfix row. function M.delete_open() local open = window.current() - local index, selected_id = selected() - local id = open and open.id or (selected_id and preview.current_id()) + local index = selected() + local id = open and open.id if not id then notify.warn("No comment card open") return @@ -297,10 +288,6 @@ function M.delete_open() notify.warn("This comment is gone") return end - if not open then - preview.close() - dismissed_id = id - end comments.delete(comment) notify.info("Deleted comment") if index then @@ -319,24 +306,12 @@ function M.delete_open() end local function escape() - local index, id = selected() - if not index then - return + if window.current() and not window.close() then + notify.warn("Save with :w, or discard with :q!") end - if window.current() then - if not window.close() then - notify.warn("Save with :w, or discard with :q!") - return - end - end - if preview.is_open() then - preview.close() - end - dismissed_id = id end --- Every comment in the quickfix list, with the one under the cursor shown --- in a card above it. +-- Open the quickfix list without moving the source cursor or showing a card. ---@param filter? string only comments matching this function M.open(filter) if not window.close() then @@ -347,11 +322,9 @@ function M.open(filter) pending_jump[bufnr] = nil end active_filter = filter - dismissed_id = nil local anchored, orphans = filtered(filter) if #anchored + #orphans == 0 then if list_id and vim.fn.getqflist({ id = 0 }).id == list_id then - preview.close() vim.fn.setqflist({}, "r", { items = {} }) vim.cmd.cclose() end @@ -359,38 +332,7 @@ function M.open(filter) notify.info("No comments") return false end - list_id = quickfix.list(anchored, orphans, function(index) - if not index then - preview.close() - return - end - local qf = vim.fn.getqflist({ items = 1 }) - local data = index and qf.items[index] and qf.items[index].user_data - local id = data and data.scratch_comments_id - local entry = id - and vim.iter(views.anchored()):find(function(view) - return view.id == id - end) - if id ~= dismissed_id then - dismissed_id = nil - end - if window.current() or dismissed_id == id then - return - end - if entry then - preview.show(card.of(entry), id) - else - local orphan = id - and vim.iter(views.orphans()):find(function(comment) - return comment.id == id - end) - if orphan then - preview.show(card.of_orphan(orphan), id) - else - preview.close() - end - end - end) + list_id = quickfix.list(anchored, orphans) if not list_id then return end diff --git a/lua/scratch_comments/ui/frame.lua b/lua/scratch_comments/ui/frame.lua index bcd5553..b584eac 100644 --- a/lua/scratch_comments/ui/frame.lua +++ b/lua/scratch_comments/ui/frame.lua @@ -24,9 +24,6 @@ end ---@field on_save? fun(text: string): boolean|string? false keeps the text unsaved; "deleted" closes the editor ---@field comment_title? string ---@field on_close? fun() ----@field enter? boolean focus the comment pane (default: true) ----@field backdrop? boolean dim the editor behind it (default: true) ----@field lines? integer rows to fit in, from the top (default: the editor's) ---@class ScratchFramePanes ---@field context_win integer @@ -35,8 +32,6 @@ end ---@field comment_buf integer ---@field backdrop_win? integer ---@field update fun(spec: ScratchFrame, preserve_comment?: boolean) ----@field lines? integer preview area retained when focusing ----@field backdrop? boolean preview dimming retained when focusing -- Two stacked floats, centered: the commented lines above, the comment below. ---@param frame ScratchFrame @@ -44,7 +39,7 @@ end function M.open(frame) ---@return { width: integer, col: integer, row: integer, context: integer, comment: integer } local function layout() - local lines = frame.lines or vim.o.lines + local lines = vim.o.lines local width = math.min(80, vim.o.columns - 4) local context = math.min(#frame.context, math.floor(lines * 0.4)) local comment = math.min(math.max(#vim.split(frame.comment, "\n"), 5), math.floor(lines * 0.3)) @@ -79,7 +74,7 @@ function M.open(frame) } end - local dim = frame.backdrop ~= false and backdrop.open(frame.lines) or nil + local dim = backdrop.open() local context_place, comment_place = placement(layout()) local border = (vim.o.winborder == "" or vim.o.winborder == "none") and "rounded" or nil @@ -100,7 +95,7 @@ function M.open(frame) end local comment_win = vim.api.nvim_open_win( comment_buf, - frame.enter ~= false, + true, vim.tbl_extend( "error", comment_place, @@ -135,7 +130,7 @@ function M.open(frame) pcall(vim.api.nvim_del_augroup_by_id, group) return end - backdrop.resize(dim, frame.lines) + backdrop.resize(dim) local context_resized, comment_resized = placement(layout()) vim.api.nvim_win_set_config(context_win, context_resized) vim.api.nvim_win_set_config(comment_win, comment_resized) @@ -170,8 +165,8 @@ function M.open(frame) comment_buf = comment_buf, backdrop_win = dim, } - -- A preview can change entries or become an editor without replacing the - -- floating windows. Keep the same placement rule for either state. + -- A parked comment can become orphaned when its source finishes loading. + -- Update the context without discarding any edits to the comment. function panes.update(spec, preserve_comment) frame = spec if preserve_comment then @@ -188,7 +183,7 @@ function M.open(frame) vim.bo[comment_buf].modified = false vim.bo[comment_buf].modifiable = false end - backdrop.resize(dim, frame.lines) + backdrop.resize(dim) local context_next, comment_next = placement(layout()) vim.api.nvim_win_set_config(context_win, context_next) vim.api.nvim_win_set_config(comment_win, comment_next) diff --git a/lua/scratch_comments/ui/preview.lua b/lua/scratch_comments/ui/preview.lua deleted file mode 100644 index b482371..0000000 --- a/lua/scratch_comments/ui/preview.lua +++ /dev/null @@ -1,86 +0,0 @@ -local frame = require("scratch_comments.ui.frame") -local backdrop = require("scratch_comments.ui.backdrop") - -local M = {} - ----@type ScratchFramePanes? the panes of the open preview -local current - ----@type ScratchFrame? what the open preview shows -local showing - ----@type string? ID displayed in the passive card -local showing_id - -function M.close() - local panes = current - current, showing, showing_id = nil, nil, nil - pcall(vim.api.nvim_del_augroup_by_name, "scratch_comments_preview") - if panes then - pcall(vim.api.nvim_win_close, panes.comment_win, true) - pcall(vim.api.nvim_win_close, panes.context_win, true) - backdrop.close(panes.backdrop_win) - end -end - ----@return boolean -function M.is_open() - return current ~= nil and vim.api.nvim_win_is_valid(current.comment_win) -end - ----@return string? -function M.current_id() - return M.is_open() and showing_id or nil -end - --- Transfer the visible card to the editor; BufLeave must no longer dismiss it. ----@return ScratchFramePanes? -function M.take() - local panes = current - if panes and showing then - panes.lines = showing.lines - panes.backdrop = showing.backdrop - end - current, showing, showing_id = nil, nil, nil - pcall(vim.api.nvim_del_augroup_by_name, "scratch_comments_preview") - if panes and vim.api.nvim_win_is_valid(panes.comment_win) then - return panes - end -end - --- A card beside whatever you are browsing: no focus, no editing. ----@param spec ScratchFrame ----@param id? string displayed comment ID -function M.show(spec, id) - local browsing = vim.api.nvim_get_current_win() - spec.enter = false - -- Fit above the window being browsed, so the card does not cover it, and - -- dim only that area so the window stays readable. - local above = vim.fn.win_screenpos(browsing)[1] - 1 - spec.lines = above >= 10 and above or nil - spec.backdrop = spec.lines ~= nil - if current and M.is_open() then - current.update(spec) - else - M.close() - current = frame.open(spec) - vim.bo[current.comment_buf].modifiable = false - end - showing, showing_id = spec, id - - -- The window being browsed moves when the editor resizes, so measure again. - local group = vim.api.nvim_create_augroup("scratch_comments_preview", { clear = true }) - vim.api.nvim_create_autocmd("VimResized", { - group = group, - callback = function() - if not showing then - return true - end - vim.api.nvim_win_call(browsing, function() - M.show(showing, showing_id) - end) - end, - }) -end - -return M diff --git a/lua/scratch_comments/ui/quickfix.lua b/lua/scratch_comments/ui/quickfix.lua index c799048..b491a5d 100644 --- a/lua/scratch_comments/ui/quickfix.lua +++ b/lua/scratch_comments/ui/quickfix.lua @@ -9,42 +9,7 @@ local function summary(text) end -- Quickfix rather than a picker: any quickfix front-end (Trouble, Snacks, --- Telescope, nvim-bqf) can display it. --- Reports the entry the cursor is on, and nil once the list is gone. ----@param on_entry? fun(index: integer?) -local function follow(on_entry) - if not on_entry then - return - end - local group = vim.api.nvim_create_augroup("scratch_comments_quickfix", { clear = true }) - local bufnr = vim.api.nvim_get_current_buf() - vim.api.nvim_create_autocmd({ "CursorMoved", "BufEnter" }, { - group = group, - buffer = bufnr, - callback = function() - on_entry(vim.api.nvim_win_get_cursor(0)[1]) - end, - }) - for _, event in ipairs({ "BufLeave", "BufWipeout" }) do - vim.api.nvim_create_autocmd(event, { - group = group, - buffer = bufnr, - callback = function() - on_entry(nil) - end, - }) - end - vim.api.nvim_create_autocmd("WinClosed", { - group = group, - pattern = tostring(vim.api.nvim_get_current_win()), - once = true, - callback = function() - on_entry(nil) - end, - }) - on_entry(vim.api.nvim_win_get_cursor(0)[1]) -end - +-- Telescope, nvim-bqf) can display the entries. ---@param views ScratchCommentView[] ---@param orphans ScratchComment[] ---@return table[] @@ -73,8 +38,7 @@ end ---@param views ScratchCommentView[] ---@param orphans ScratchComment[] ----@param on_entry? fun(index: integer?) called with the entry the cursor is on -function M.list(views, orphans, on_entry) +function M.list(views, orphans) if #views + #orphans == 0 then notify.info("No comments") return @@ -82,7 +46,6 @@ function M.list(views, orphans, on_entry) vim.fn.setqflist({}, " ", { title = "Comments", items = M.items(views, orphans) }) vim.cmd.copen() - follow(on_entry) return vim.fn.getqflist({ id = 0 }).id end diff --git a/lua/scratch_comments/ui/window.lua b/lua/scratch_comments/ui/window.lua index 1c0cba3..944dcd4 100644 --- a/lua/scratch_comments/ui/window.lua +++ b/lua/scratch_comments/ui/window.lua @@ -1,6 +1,5 @@ local frame = require("scratch_comments.ui.frame") local notify = require("scratch_comments.ui.notify") -local preview = require("scratch_comments.ui.preview") local M = {} @@ -30,12 +29,7 @@ local current ---@param spec ScratchFrame ---@param record ScratchCommentWindow ----@param panes? ScratchFramePanes existing preview panes to focus in place -function M.open(spec, record, panes) - local promoted = panes ~= nil - if not panes then - preview.close() - end +function M.open(spec, record) spec.on_close = function() if current and current.comment_win == record.comment_win then current = nil @@ -49,22 +43,8 @@ function M.open(spec, record, panes) or "comment" local source_name = vim.fn.fnamemodify(source_buffer_name, ":t") spec.comment_name = source_name .. ":" .. record.line .. " [comment]" - if panes then - spec.lines = panes.lines - spec.backdrop = panes.backdrop - panes.update(spec) - else - panes = frame.open(spec) - end + local panes = frame.open(spec) local comment_win, comment_buf = panes.comment_win, panes.comment_buf - if promoted then - vim.api.nvim_buf_set_name( - comment_buf, - "scratch-comments://" .. comment_buf .. "/" .. spec.comment_name - ) - vim.bo[comment_buf].modifiable = true - vim.api.nvim_set_current_win(comment_win) - end record.comment_win = comment_win current = record @@ -82,8 +62,6 @@ function M.open(spec, record, panes) record.update = function(next_spec) next_spec.on_close = spec.on_close next_spec.on_save = spec.on_save - next_spec.lines = spec.lines - next_spec.backdrop = spec.backdrop panes.update(next_spec, true) spec = next_spec show_mode() @@ -140,7 +118,7 @@ function M.close() return false end -- A command replacing the card (e.g. :CommentExport!) owns the next focus. - -- Only a user closing the card should return to its list. + -- Only a user closing the card should return to its source or list. open.on_close = nil pcall(vim.api.nvim_win_close, open.comment_win, true) return true diff --git a/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua index 3e7966c..f198e5f 100644 --- a/tests/features/anchoring_spec.lua +++ b/tests/features/anchoring_spec.lua @@ -129,6 +129,7 @@ describe("an orphan", function() it("can be deleted from the comment list", function() vim.cmd.CommentList() + vim.cmd("normal \r") vim.cmd.CommentDelete() expect.equality(#views.orphans(), 0) end) @@ -236,6 +237,7 @@ describe("a file's comments", function() expect.equality(#views.orphans(), 1) expect.equality(#views.anchored(), 0) vim.cmd.CommentList() + vim.cmd("normal \r") vim.cmd.CommentDelete() expect.equality(#store.all(), 0) end) diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index 5a0f17f..c99af45 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -213,14 +213,14 @@ describe("the comment list manager", function() vim.cmd.cclose() end) - it("deletes the displayed comment and keeps the filtered list", function() + it("deletes the opened comment and keeps the filtered list", function() vim.cmd("CommentList export-fixture") local removed = vim.fn.getqflist()[1].user_data.scratch_comments_id + vim.cmd("normal \r") vim.cmd.CommentDelete() expect.equality(#store.all(), 1) expect.no_equality(store.all()[1].id, removed) expect.equality(#vim.fn.getqflist(), 1) - expect.equality(vim.bo.buftype, "quickfix") vim.cmd.cclose() end) @@ -234,10 +234,11 @@ describe("the comment list manager", function() vim.cmd.cclose() end) - it("deletes a parked comment without opening its source", function() + it("deletes a parked comment after opening its card", function() vim.cmd("bwipeout! " .. source) expect.equality(#store.all(), 2) vim.cmd.CommentList() + vim.cmd("normal \r") vim.cmd.CommentDelete() expect.equality(#store.all(), 1) expect.equality(store.all()[1].comment, "on c") @@ -255,7 +256,7 @@ describe("the comment list manager", function() return item.text:find("[orphaned]", 1, true) ~= nil end) vim.api.nvim_win_set_cursor(0, { index, 0 }) - vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) + vim.cmd("normal \r") vim.cmd.CommentDelete() expect.equality(#store.all(), 1) expect.equality(store.all()[1].comment, "on c") @@ -450,12 +451,12 @@ describe(":CommentList with a filter", function() end) describe("one card at a time", function() - it(":CommentList replaces an open comment window", function() + it(":CommentList closes an open comment window without opening another", function() vim.cmd.CommentList() vim.cmd("normal \r") expect.equality(#helpers.floats(), 2) vim.cmd("CommentList") - expect.equality(#helpers.floats(), 2) + expect.equality(#helpers.floats(), 0) expect.equality(vim.bo.buftype, "quickfix") end) @@ -476,7 +477,7 @@ describe("one card at a time", function() expect.equality(vim.bo.buftype, "nofile") end) - it("a comment window replaces the list's card", function() + it("opens a new comment even with the list visible", function() vim.cmd("CommentList") vim.cmd("wincmd k") vim.cmd("2Comment") @@ -484,58 +485,41 @@ describe("one card at a time", function() end) end) -describe("the card beside the list", function() - ---@return string - local function card() - local panes = vim.tbl_filter(function(win) - return vim.bo[vim.api.nvim_win_get_buf(win)].filetype == "markdown" - end, helpers.floats()) - return panes[1] and helpers.text(vim.api.nvim_win_get_buf(panes[1])) or "" - end - - ---@param line integer - local function move_to(line) - vim.api.nvim_win_set_cursor(0, { line, 0 }) - vim.api.nvim_exec_autocmds("CursorMoved", { buffer = vim.api.nvim_get_current_buf() }) - end - - it("shows the comment the cursor is on, reusing the card as it follows", function() - vim.cmd("CommentList") - local panes = helpers.floats() - expect.equality(card(), "on a") - move_to(2) - expect.equality(card(), "on c") - expect.equality(helpers.floats(), panes) - move_to(1) - expect.equality(card(), "on a") +describe("quickfix navigation", function() + it("moves through entries without opening a card or moving the source cursor", function() + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + local source_win = vim.api.nvim_get_current_win() + vim.cmd.CommentList() + vim.cmd("normal j") + expect.equality(vim.api.nvim_win_get_cursor(0)[1], 2) + expect.equality(vim.fn.getqflist({ idx = 0 }).idx, 1) + expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 1) + expect.equality(#helpers.every_float(), 0) end) - it("focuses the same card in the same place on ", function() + it("opens an editable card only after jumps to the source", function() vim.cmd.CommentList() - local panes = helpers.floats() - local comment_win = vim.iter(panes):find(function(win) - return vim.bo[vim.api.nvim_win_get_buf(win)].filetype == "markdown" - end) - local before = vim.api.nvim_win_get_config(comment_win) + expect.equality(#helpers.every_float(), 0) + vim.cmd("normal j") vim.cmd("normal \r") - local after = vim.api.nvim_win_get_config(comment_win) - expect.equality(helpers.floats(), panes) - expect.equality({ after.row, after.col, after.width }, { before.row, before.col, before.width }) - expect.equality(vim.api.nvim_get_current_win(), comment_win) + expect.equality(helpers.text(), "on c") expect.equality(vim.bo.modifiable, true) + expect.equality(#helpers.floats(), 2) + local dim = vim.tbl_filter(function(win) + return vim.w[win].scratch_comments_backdrop + end, helpers.every_float()) + expect.equality(vim.api.nvim_win_get_config(dim[1]).height, vim.o.lines) vim.cmd.quit() vim.wait(0) expect.equality(vim.api.nvim_get_current_buf(), source) + expect.equality(vim.api.nvim_win_get_cursor(0)[1], 3) end) - it("dismisses a preview on until the row changes", function() + it("has nothing to dismiss on before opening a card", function() vim.cmd.CommentList() vim.cmd([[execute "normal \"]]) - expect.equality(#helpers.floats(), 0) - vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) - expect.equality(#helpers.floats(), 0) - move_to(2) - expect.equality(card(), "on c") + expect.equality(vim.bo.buftype, "quickfix") + expect.equality(#helpers.every_float(), 0) end) it("closes a clean editor on from the list, but preserves unsaved edits", function() @@ -550,52 +534,13 @@ describe("the card beside the list", function() expect.equality(helpers.notified()[#helpers.notified()], "Save with :w, or discard with :q!") vim.bo[comment_buf].modified = false vim.cmd([[execute "normal \"]]) - expect.equality(#helpers.floats(), 0) + expect.equality(#helpers.every_float(), 0) expect.equality(vim.bo.buftype, "quickfix") end) - it("dims what is above it, leaving the list readable", function() - vim.cmd("CommentList") - local dim = vim.tbl_filter(function(win) - return vim.w[win].scratch_comments_backdrop - end, helpers.every_float()) - expect.equality(#dim, 1) - local config = vim.api.nvim_win_get_config(dim[1]) - expect.equality(config.height < vim.o.lines, true) - expect.equality(config.height < vim.fn.win_screenpos(0)[1], true) - end) - - ---@return vim.api.keyset.win_config - local function card_config() - local panes = vim.tbl_filter(function(win) - return vim.bo[vim.api.nvim_win_get_buf(win)].filetype == "markdown" - end, helpers.floats()) - return vim.api.nvim_win_get_config(panes[1]) - end - - it("is measured again when the editor resizes", function() - local columns, lines = vim.o.columns, vim.o.lines - vim.o.lines = 44 - vim.cmd("CommentList") - local before = card_config() - vim.o.lines = 24 - vim.api.nvim_exec_autocmds("VimResized", {}) - local after = card_config() - -- A shorter editor leaves less room above the list, so the card moves up - -- instead of covering it. - expect.equality(after.row < before.row, true) - expect.equality(after.row + after.height + 1 <= vim.fn.win_screenpos(0)[1], true) - vim.o.columns, vim.o.lines = columns, lines - end) - - it("keeps the focus in the list", function() - vim.cmd("CommentList") - expect.equality(vim.bo.buftype, "quickfix") - end) - - it("goes away with the list", function() - vim.cmd("CommentList") - vim.api.nvim_exec_autocmds("BufLeave", { buffer = vim.api.nvim_get_current_buf() }) + it("does not leave any card behind when the list closes", function() + vim.cmd.CommentList() + vim.cmd.cclose() expect.equality(#helpers.every_float(), 0) end) end) diff --git a/tests/features/show_delete_spec.lua b/tests/features/show_delete_spec.lua index 77743df..90db5ac 100644 --- a/tests/features/show_delete_spec.lua +++ b/tests/features/show_delete_spec.lua @@ -14,19 +14,11 @@ before_each(function() end) describe(":CommentDelete", function() - it("deletes the displayed card while the list has focus", function() - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) - vim.cmd.CommentDelete() - expect.equality(#store.all(), 1) - expect.equality(views.anchored()[1].comment, "on one") - end) - - it("deletes the card that is visible, not a row changed without a redraw", function() + it("does not delete a merely selected quickfix entry", function() vim.api.nvim_win_set_cursor(0, { 2, 0 }) vim.cmd.CommentDelete() - expect.equality(#store.all(), 1) - expect.equality(store.all()[1].comment, "on one to three") + expect.equality(#store.all(), 2) + expect.equality(helpers.notified()[#helpers.notified()], "No comment card open") end) it("deletes the focused card by its ID", function() @@ -47,8 +39,9 @@ describe(":CommentDelete", function() expect.equality(#store.all(), 1) end) - it("does nothing without an open card", function() - vim.cmd([[execute "normal \"]]) + it("does nothing after a card has closed", function() + vim.cmd("normal \r") + vim.cmd.quit() vim.cmd.CommentDelete() expect.equality(#store.all(), 2) expect.equality(helpers.notified()[#helpers.notified()], "No comment card open") diff --git a/tests/features/window_spec.lua b/tests/features/window_spec.lua index 26b4b17..08ba648 100644 --- a/tests/features/window_spec.lua +++ b/tests/features/window_spec.lua @@ -118,11 +118,7 @@ describe("the comment window", function() end, helpers.every_float()) expect.equality(#dim, 1) local config = vim.api.nvim_win_get_config(dim[1]) - local qf_win = vim.fn.getqflist({ winid = 0 }).winid - expect.equality( - { config.width, config.height }, - { vim.o.columns, vim.fn.win_screenpos(qf_win)[1] - 1 } - ) + expect.equality({ config.width, config.height }, { vim.o.columns, vim.o.lines }) expect.equality(config.zindex < vim.api.nvim_win_get_config(0).zindex, true) expect.equality(vim.wo[dim[1]].winblend, 60) vim.cmd("quit") -- 2.54.0 From d3be09d99bfc2c79e80ee41645af568c8d3636ca Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 30 Sep 2026 06:52:24 -0700 Subject: [PATCH 11/13] Keep comment cards inside the editing window --- README.md | 6 +- doc/scratch-comments.txt | 7 +- lua/scratch_comments/location.lua | 3 + lua/scratch_comments/ui/backdrop.lua | 30 +++--- lua/scratch_comments/ui/frame.lua | 135 +++++++++++++++++++++++---- lua/scratch_comments/ui/window.lua | 46 ++++++++- tests/features/list_export_spec.lua | 89 +++++++++++++++++- tests/features/window_spec.lua | 92 +++++++++++++++++- tests/unit/location_spec.lua | 5 + 9 files changed, 370 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 9740da9..e46e93c 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ comment on them, or just a word or phrase to comment on only that: :'<,'>Comment ``` -a window opens in the middle of the screen, with the lines you're commenting on +a window opens over the file, with the lines you're commenting on at the top and your comment below. a new comment opens in insert mode, and the title shows the mode you're in. write as many lines as you like, then `:wq` to save. `:q` or `` closes it if you haven't changed anything, and `:q!` @@ -99,8 +99,8 @@ vim.keymap.set("n", "cx", "CommentExport", { desc = "Copy comme `:CommentList` puts all your comments in the quickfix list. move through them with `j`/`k` without moving the source cursor or opening a card. `` jumps -to the source when the comment has a location, then opens its card for editing. -`:w` saves; `:CommentDelete` deletes the comment in an open card, even if you +to the source when the comment has a location, then opens its card over the +file, leaving quickfix visible. `:w` saves; `:CommentDelete` deletes the comment in an open card, even if you focus the list again. `` closes a clean card; unsaved edits need `:w` or `:q!`. an orphan has no line to jump to, so its editor returns to the list. give `:CommentList` an argument to fuzzy match text, snippet or path: diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index 8a718f6..f08b0ed 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -37,7 +37,7 @@ Requires Neovim 0.12 or newer. already has a comment, open it for editing instead of adding a duplicate. - A window opens in the middle of the screen: the lines + A window opens over the file: the lines you are commenting on at the top, your comment below. A new comment opens in insert mode, and the title shows the mode you are in. Write as many lines as you @@ -52,8 +52,9 @@ Requires Neovim 0.12 or newer. in their text, snippet or path (see |matchfuzzy()|). Moving through the built-in list does not move the source cursor or open a card. jumps to the - comment's source location and opens its card for - editing in normal mode. An orphan has no location; + comment's source location and opens its card over the + file, leaving quickfix visible. It starts in normal + mode. An orphan has no location; edits it without a jump. |:w| saves; saving it empty deletes it. closes a clean card from the list or the card; unsaved changes require |:w| or diff --git a/lua/scratch_comments/location.lua b/lua/scratch_comments/location.lua index 1724bd6..fc77988 100644 --- a/lua/scratch_comments/location.lua +++ b/lua/scratch_comments/location.lua @@ -38,6 +38,9 @@ end ---@param width integer ---@return string function M.fit(text, width) + if width <= 0 then + return "" + end local start = 0 while vim.api.nvim_strwidth(vim.fn.strcharpart(text, start)) > width do start = start + 1 diff --git a/lua/scratch_comments/ui/backdrop.lua b/lua/scratch_comments/ui/backdrop.lua index a3dcbca..eca36d1 100644 --- a/lua/scratch_comments/ui/backdrop.lua +++ b/lua/scratch_comments/ui/backdrop.lua @@ -4,22 +4,23 @@ local group = "ScratchCommentBackdrop" vim.api.nvim_set_hl(0, group, { bg = "#000000", default = true }) --- Dims the editor behind the comment window. Blending needs true colors, so --- without them there is nothing to dim with. ----@param lines? integer rows to cover, from the top (default: the editor's) +-- Dim only the editing window, leaving quickfix and other splits readable. +-- Blending needs true colors, so without them there is nothing to dim with. +---@param parent integer editing window ---@return integer? win -function M.open(lines) +function M.open(parent) if not vim.o.termguicolors then return nil end local bufnr = vim.api.nvim_create_buf(false, true) vim.bo[bufnr].bufhidden = "wipe" local win = vim.api.nvim_open_win(bufnr, false, { - relative = "editor", + relative = "win", + win = parent, row = 0, col = 0, - width = vim.o.columns, - height = lines or vim.o.lines, + width = vim.api.nvim_win_get_width(parent), + height = vim.api.nvim_win_get_height(parent), focusable = false, style = "minimal", -- Below the comment window, which opens at the default 50. @@ -33,16 +34,17 @@ function M.open(lines) return win end ----@param win? integer ----@param lines? integer -function M.resize(win, lines) - if win and vim.api.nvim_win_is_valid(win) then +---@param win? integer backdrop window +---@param parent integer editing window +function M.resize(win, parent) + if win and vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_is_valid(parent) then vim.api.nvim_win_set_config(win, { - relative = "editor", + relative = "win", + win = parent, row = 0, col = 0, - width = vim.o.columns, - height = lines or vim.o.lines, + width = vim.api.nvim_win_get_width(parent), + height = vim.api.nvim_win_get_height(parent), }) end end diff --git a/lua/scratch_comments/ui/frame.lua b/lua/scratch_comments/ui/frame.lua index b584eac..c389e0a 100644 --- a/lua/scratch_comments/ui/frame.lua +++ b/lua/scratch_comments/ui/frame.lua @@ -1,5 +1,6 @@ local backdrop = require("scratch_comments.ui.backdrop") local location = require("scratch_comments.location") +local notify = require("scratch_comments.ui.notify") local M = {} @@ -35,55 +36,87 @@ end -- Two stacked floats, centered: the commented lines above, the comment below. ---@param frame ScratchFrame +---@param parent integer editing window to cover, not the quickfix list ---@return ScratchFramePanes -function M.open(frame) - ---@return { width: integer, col: integer, row: integer, context: integer, comment: integer } +function M.open(frame, parent) + ---@return { width: integer, col: integer, row: integer, context: integer, comment: integer, compact: boolean } local function layout() - local lines = vim.o.lines - local width = math.min(80, vim.o.columns - 4) - local context = math.min(#frame.context, math.floor(lines * 0.4)) - local comment = math.min(math.max(#vim.split(frame.comment, "\n"), 5), math.floor(lines * 0.3)) + local lines = vim.api.nvim_win_get_height(parent) + local columns = vim.api.nvim_win_get_width(parent) + local compact = lines < 6 + local width = math.max(1, math.min(80, columns - (compact and 0 or 4))) + if compact then + -- Two bordered panes cannot fit. The comment covers the context pane, + -- which stays open so edits and normal resizing keep the same buffers. + return { + width = width, + col = math.floor((columns - width) / 2), + row = 0, + context = 1, + comment = lines, + compact = true, + } + end + local available = lines - 4 + local context = math.max(1, math.min(#frame.context, math.floor(lines * 0.4), available - 1)) + local comment = math.max( + 1, + math.min( + math.max(#vim.split(frame.comment, "\n"), 5), + math.floor(lines * 0.3), + available - context + ) + ) return { width = width, - col = math.floor((vim.o.columns - width) / 2), + col = math.floor((columns - width) / 2), -- Each pane has a top and a bottom border line. row = math.floor((lines - context - comment - 4) / 2), context = context, comment = comment, + compact = false, } end + local border = (vim.o.winborder == "" or vim.o.winborder == "none") and "rounded" + or vim.o.winborder ---@param place table ---@return vim.api.keyset.win_config context ---@return vim.api.keyset.win_config comment local function placement(place) + local narrow = place.width < 4 return { - relative = "editor", + relative = "win", + win = parent, row = place.row, col = place.col, width = place.width, - height = place.context, - title = " " .. location.fit(frame.title, place.width - 2) .. " ", + height = place.compact and place.comment or place.context, + title = (place.compact or narrow) and "" + or " " .. location.fit(frame.title, place.width - 2) .. " ", title_pos = "center", + border = place.compact and "none" or border, + zindex = 45, }, { - relative = "editor", - row = place.row + place.context + 2, + relative = "win", + win = parent, + row = place.compact and place.row or place.row + place.context + 2, col = place.col, width = place.width, height = place.comment, + border = place.compact and "none" or border, + zindex = 50, } end - local dim = backdrop.open() + local dim = backdrop.open(parent) local context_place, comment_place = placement(layout()) - local border = (vim.o.winborder == "" or vim.o.winborder == "none") and "rounded" or nil - local context_buf = frame_buffer(frame.context, frame.filetype) vim.bo[context_buf].modifiable = false local context_win = vim.api.nvim_open_win( context_buf, false, - vim.tbl_extend("error", context_place, { border = border, style = "minimal" }) + vim.tbl_extend("error", context_place, { style = "minimal" }) ) local comment_buf = frame_buffer(vim.split(frame.comment, "\n"), "markdown") @@ -99,7 +132,7 @@ function M.open(frame) vim.tbl_extend( "error", comment_place, - { border = border, style = "minimal", title = " comment ", title_pos = "center" } + { style = "minimal", title = " comment ", title_pos = "center" } ) ) vim.wo[comment_win].wrap = true @@ -120,7 +153,66 @@ function M.open(frame) end local group = vim.api.nvim_create_augroup("scratch_comments_frame_" .. comment_win, {}) - vim.api.nvim_create_autocmd("VimResized", { + local tab = vim.api.nvim_win_get_tabpage(parent) + local function watch_parent() + vim.api.nvim_create_autocmd("WinClosed", { + group = group, + pattern = tostring(parent), + once = true, + callback = function() + vim.schedule(function() + if + not ( + vim.api.nvim_tabpage_is_valid(tab) + and vim.api.nvim_win_is_valid(comment_win) + and vim.api.nvim_win_is_valid(context_win) + ) + then + return + end + local file_win, other_win, qf_win + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tab)) do + if vim.api.nvim_win_get_config(win).relative == "" then + local kind = vim.bo[vim.api.nvim_win_get_buf(win)].buftype + if kind == "" then + file_win = file_win or win + elseif kind == "quickfix" then + qf_win = win + else + other_win = other_win or win + end + end + end + local replacement = file_win or other_win + if not replacement and qf_win then + if not vim.bo[comment_buf].modified then + vim.api.nvim_win_close(comment_win, true) + return + end + -- Preserve unsaved text even if the editor cannot make a split. + local ok, created = pcall(vim.api.nvim_win_call, qf_win, function() + vim.cmd("aboveleft new") + return vim.api.nvim_get_current_win() + end) + if not ok then + notify.warn("No room for an editing window; save or discard the open card") + end + replacement = ok and created or qf_win + end + if replacement then + parent = replacement + backdrop.resize(dim, parent) + local context_next, comment_next = placement(layout()) + vim.api.nvim_win_set_config(context_win, context_next) + vim.api.nvim_win_set_config(comment_win, comment_next) + watch_parent() + end + end) + end, + }) + end + watch_parent() + vim.api.nvim_create_autocmd({ "VimResized", "WinResized" }, { group = group, callback = function() if @@ -130,7 +222,10 @@ function M.open(frame) pcall(vim.api.nvim_del_augroup_by_id, group) return end - backdrop.resize(dim) + if not vim.api.nvim_win_is_valid(parent) then + return + end + backdrop.resize(dim, parent) local context_resized, comment_resized = placement(layout()) vim.api.nvim_win_set_config(context_win, context_resized) vim.api.nvim_win_set_config(comment_win, comment_resized) @@ -183,7 +278,7 @@ function M.open(frame) vim.bo[comment_buf].modified = false vim.bo[comment_buf].modifiable = false end - backdrop.resize(dim) + backdrop.resize(dim, parent) local context_next, comment_next = placement(layout()) vim.api.nvim_win_set_config(context_win, context_next) vim.api.nvim_win_set_config(comment_win, comment_next) diff --git a/lua/scratch_comments/ui/window.lua b/lua/scratch_comments/ui/window.lua index 944dcd4..5e29b58 100644 --- a/lua/scratch_comments/ui/window.lua +++ b/lua/scratch_comments/ui/window.lua @@ -27,6 +27,46 @@ local mode_names = { ---@type ScratchCommentWindow? local current +-- A quickfix row with no source location still edits in the file area, while +-- closing its card returns focus to the list. +---@param record ScratchCommentWindow +---@return integer? +local function editing_window(record) + local source = record.source_win + if + vim.api.nvim_win_is_valid(source) + and vim.bo[vim.api.nvim_win_get_buf(source)].buftype ~= "quickfix" + then + return source + end + local file_win, other_win + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.api.nvim_win_get_config(win).relative == "" then + local bufnr = vim.api.nvim_win_get_buf(win) + if record.source_name and vim.api.nvim_buf_get_name(bufnr) == record.source_name then + return win + end + if vim.bo[bufnr].buftype == "" then + file_win = file_win or win + elseif vim.bo[bufnr].buftype ~= "quickfix" then + other_win = other_win or win + end + end + end + if file_win or other_win then + return file_win or other_win + end + -- Quickfix cannot provide a source area on its own. Make one if there is + -- room; otherwise keep the list usable rather than drawing over it. + if not pcall(function() + vim.cmd("aboveleft new") + end) then + notify.warn("Make room for an editing window to open this comment") + return nil + end + return vim.api.nvim_get_current_win() +end + ---@param spec ScratchFrame ---@param record ScratchCommentWindow function M.open(spec, record) @@ -43,7 +83,11 @@ function M.open(spec, record) or "comment" local source_name = vim.fn.fnamemodify(source_buffer_name, ":t") spec.comment_name = source_name .. ":" .. record.line .. " [comment]" - local panes = frame.open(spec) + local parent = editing_window(record) + if not parent then + return + end + local panes = frame.open(spec, parent) local comment_win, comment_buf = panes.comment_win, panes.comment_buf record.comment_win = comment_win current = record diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index c99af45..9749352 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -189,12 +189,77 @@ describe("the comment list manager", function() local qf_win = vim.api.nvim_get_current_win() vim.cmd("normal \r") expect.equality(helpers.text(), "on a") + local dim = vim.iter(helpers.every_float()):find(function(win) + return vim.w[win].scratch_comments_backdrop + end) + local parent = assert(vim.api.nvim_win_get_config(dim).win) + expect.equality(vim.api.nvim_win_get_buf(parent), source) + expect.no_equality(parent, qf_win) vim.cmd.quit() vim.wait(0) expect.equality(vim.api.nvim_get_current_win(), qf_win) expect.equality(vim.bo.buftype, "quickfix") end) + it("places an orphan card over a file, not an export scratch split", function() + vim.cmd("1d") + helpers.text_changed() + vim.cmd("CommentExport!") + expect.equality(vim.bo.buftype, "nofile") + vim.cmd.CommentList() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.cmd("normal \r") + local dim = vim.iter(helpers.every_float()):find(function(win) + return vim.w[win].scratch_comments_backdrop + end) + local parent = assert(vim.api.nvim_win_get_config(dim).win) + expect.equality(vim.api.nvim_win_get_buf(parent), source) + end) + + it("makes a file area if only quickfix remains for an orphan", function() + vim.cmd("1d") + helpers.text_changed() + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + local file_win = vim.fn.bufwinid(vim.api.nvim_buf_get_name(source)) + vim.api.nvim_win_close(file_win, true) + vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) + vim.cmd("normal \r") + local dim = vim.iter(helpers.every_float()):find(function(win) + return vim.w[win].scratch_comments_backdrop + end) + local parent = assert(vim.api.nvim_win_get_config(dim).win) + expect.no_equality(parent, qf_win) + expect.equality(vim.api.nvim_win_is_valid(qf_win), true) + vim.cmd.quit() + vim.wait(0) + expect.equality(vim.api.nvim_get_current_win(), qf_win) + end) + + it("keeps quickfix usable if there is no room to open an orphan card", function() + vim.cmd("1d") + helpers.text_changed() + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_close(vim.fn.bufwinid(vim.api.nvim_buf_get_name(source)), true) + local min_height, preferred = vim.o.winminheight, vim.o.winheight + local ok = pcall(function() + vim.o.winheight = 12 + vim.o.winminheight = 12 + vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) + vim.cmd("normal \r") + end) + vim.o.winminheight = min_height + vim.o.winheight = preferred + expect.equality(ok, true) + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(#helpers.floats(), 0) + expect.equality( + helpers.notified()[#helpers.notified()], + "Make room for an editing window to open this comment" + ) + end) + it("deletes an orphan by saving its card empty", function() vim.api.nvim_set_current_buf(source) vim.cmd("1d") @@ -508,13 +573,35 @@ describe("quickfix navigation", function() local dim = vim.tbl_filter(function(win) return vim.w[win].scratch_comments_backdrop end, helpers.every_float()) - expect.equality(vim.api.nvim_win_get_config(dim[1]).height, vim.o.lines) + local source_win = require("scratch_comments.ui.window").current().source_win + local config = vim.api.nvim_win_get_config(dim[1]) + expect.equality(config.relative, "win") + expect.equality(config.win, source_win) + expect.equality(config.height, vim.api.nvim_win_get_height(source_win)) + local qf_win = vim.fn.getqflist({ winid = 0 }).winid + expect.equality( + vim.fn.win_screenpos(source_win)[1] + config.height <= vim.fn.win_screenpos(qf_win)[1], + true + ) vim.cmd.quit() vim.wait(0) expect.equality(vim.api.nvim_get_current_buf(), source) expect.equality(vim.api.nvim_win_get_cursor(0)[1], 3) end) + it("keeps another editing split visible behind the card", function() + vim.cmd.vsplit() + vim.cmd.CommentList() + vim.cmd("normal \r") + local dim = vim.iter(helpers.every_float()):find(function(win) + return vim.w[win].scratch_comments_backdrop + end) + local config = vim.api.nvim_win_get_config(dim) + expect.equality(config.relative, "win") + expect.equality(config.width < vim.o.columns, true) + expect.equality(vim.api.nvim_win_get_buf(config.win), source) + end) + it("has nothing to dismiss on before opening a card", function() vim.cmd.CommentList() vim.cmd([[execute "normal \"]]) diff --git a/tests/features/window_spec.lua b/tests/features/window_spec.lua index 08ba648..40b2d64 100644 --- a/tests/features/window_spec.lua +++ b/tests/features/window_spec.lua @@ -94,6 +94,76 @@ describe("the comment window", function() expect.equality(helpers.title(lines_win), " notes.md [lines 1–3] ") end) + it("resizes with its source window when quickfix closes", function() + local source_win = require("scratch_comments.ui.window").current().source_win + local dim = vim.iter(helpers.every_float()):find(function(win) + return vim.w[win].scratch_comments_backdrop + end) + local before = vim.api.nvim_win_get_config(dim).height + vim.cmd.cclose() + vim.api.nvim_exec_autocmds("WinResized", {}) + expect.equality(vim.api.nvim_win_get_height(source_win) > before, true) + expect.equality( + vim.api.nvim_win_get_config(dim).height, + vim.api.nvim_win_get_height(source_win) + ) + end) + + it("keeps a small card inside a very short source window", function() + local record = assert(require("scratch_comments.ui.window").current()) + ---@cast record { comment_win: integer, source_win: integer } + local comment_buf = vim.api.nvim_win_get_buf(record.comment_win) + vim.api.nvim_buf_set_lines(comment_buf, 0, -1, false, { "unsaved" }) + vim.api.nvim_set_current_win(record.source_win) + vim.cmd("resize 3") + vim.api.nvim_exec_autocmds("WinResized", {}) + local height = vim.api.nvim_win_get_height(record.source_win) + local config = vim.api.nvim_win_get_config(record.comment_win) + expect.equality(height < 6, true) + expect.equality(config.row + config.height <= height, true) + expect.equality(vim.bo[comment_buf].modified, true) + expect.equality(helpers.text(comment_buf), "unsaved") + end) + + it("keeps a card in a narrow editing split", function() + local record = assert(require("scratch_comments.ui.window").current()) + ---@cast record { comment_win: integer, source_win: integer } + vim.api.nvim_set_current_win(record.source_win) + vim.cmd.vsplit() + vim.api.nvim_set_current_win(record.source_win) + vim.cmd("vertical resize 4") + vim.api.nvim_exec_autocmds("WinResized", {}) + local config = vim.api.nvim_win_get_config(record.comment_win) + expect.equality(vim.api.nvim_win_get_width(record.source_win) <= 5, true) + expect.equality(config.width <= vim.api.nvim_win_get_width(record.source_win), true) + end) + + it("keeps unsaved edits when the source window closes", function() + local record = assert(require("scratch_comments.ui.window").current()) + ---@cast record { comment_win: integer, source_win: integer } + local comment_buf = vim.api.nvim_win_get_buf(record.comment_win) + vim.api.nvim_buf_set_lines(comment_buf, 0, -1, false, { "unsaved" }) + vim.api.nvim_set_current_win(record.source_win) + vim.api.nvim_win_close(record.source_win, true) + vim.wait(0) + expect.equality(vim.api.nvim_win_is_valid(record.comment_win), true) + expect.equality(helpers.text(comment_buf), "unsaved") + expect.equality(vim.bo[comment_buf].modified, true) + local parent = assert(vim.api.nvim_win_get_config(record.comment_win).win) + expect.equality(vim.api.nvim_win_is_valid(parent), true) + expect.no_equality(vim.bo[vim.api.nvim_win_get_buf(parent)].buftype, "quickfix") + end) + + it("closes a clean card if its source window was the last file pane", function() + local record = assert(require("scratch_comments.ui.window").current()) + ---@cast record { comment_win: integer, source_win: integer } + vim.api.nvim_set_current_win(record.source_win) + vim.api.nvim_win_close(record.source_win, true) + vim.wait(0) + expect.equality(vim.api.nvim_win_is_valid(record.comment_win), false) + expect.equality(vim.bo.buftype, "quickfix") + end) + it("closes on in normal mode", function() vim.cmd([[execute "normal \"]]) expect.equality(#helpers.floats(), 0) @@ -118,7 +188,13 @@ describe("the comment window", function() end, helpers.every_float()) expect.equality(#dim, 1) local config = vim.api.nvim_win_get_config(dim[1]) - expect.equality({ config.width, config.height }, { vim.o.columns, vim.o.lines }) + local source_win = require("scratch_comments.ui.window").current().source_win + expect.equality(config.relative, "win") + expect.equality(config.win, source_win) + expect.equality( + { config.width, config.height }, + { vim.api.nvim_win_get_width(source_win), vim.api.nvim_win_get_height(source_win) } + ) expect.equality(config.zindex < vim.api.nvim_win_get_config(0).zindex, true) expect.equality(vim.wo[dim[1]].winblend, 60) vim.cmd("quit") @@ -169,4 +245,18 @@ describe("the border", function() vim.cmd("normal \r") expect.equality(vim.api.nvim_win_get_config(0).border[1], "┌") end) + + it("restores a custom border when a short source window grows again", function() + vim.o.winborder = "single" + vim.cmd.CommentList() + vim.cmd("normal \r") + local record = assert(require("scratch_comments.ui.window").current()) + ---@cast record { comment_win: integer, source_win: integer } + vim.api.nvim_set_current_win(record.source_win) + vim.cmd("resize 3") + vim.api.nvim_exec_autocmds("WinResized", {}) + vim.cmd("resize 12") + vim.api.nvim_exec_autocmds("WinResized", {}) + expect.equality(vim.api.nvim_win_get_config(record.comment_win).border[1], "┌") + end) end) diff --git a/tests/unit/location_spec.lua b/tests/unit/location_spec.lua index a3ba608..260cafa 100644 --- a/tests/unit/location_spec.lua +++ b/tests/unit/location_spec.lua @@ -34,6 +34,11 @@ describe("title", function() end) describe("fit", function() + it("returns no title when no room is available", function() + expect.equality(location.fit("app.lua", 0), "") + expect.equality(location.fit("app.lua", -1), "") + end) + it("leaves text that fits alone", function() expect.equality(location.fit("app.lua [line 3]", 16), "app.lua [line 3]") end) -- 2.54.0 From d7846e9e18e17bd251f06a4457dba953d8d37b11 Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 30 Sep 2026 07:11:13 -0700 Subject: [PATCH 12/13] Delete quickfix cursor comment without opening its card --- README.md | 19 +++---- doc/scratch-comments.txt | 13 +++-- lua/scratch_comments/init.lua | 2 +- lua/scratch_comments/list.lua | 29 ++++++---- tests/features/list_export_spec.lua | 18 +++++++ tests/features/show_delete_spec.lua | 84 +++++++++++++++++++++++++++-- 6 files changed, 136 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e46e93c..0c75f96 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ it, pipe it to a command, or edit it: | --- | --- | | `:Comment` | Add a comment on the current line or range, or edit one already on that exact range | | `:CommentList [filter]` | Put comments in the quickfix list and open it, fuzzy matching `filter` | -| `:CommentDelete` | Delete the comment in the open card | +| `:CommentDelete` | Delete the cursor row in the built-in quickfix list, or the open card elsewhere | | `:CommentExport[!] [format]` | Copy all comments to the clipboard as `markdown` (default) or `json`. `!` opens them in a scratch buffer | | `:CommentToggle [on\|off]` | Show or hide the comment signs | | `:CommentClear` | Delete every comment | @@ -100,9 +100,10 @@ vim.keymap.set("n", "cx", "CommentExport", { desc = "Copy comme `:CommentList` puts all your comments in the quickfix list. move through them with `j`/`k` without moving the source cursor or opening a card. `` jumps to the source when the comment has a location, then opens its card over the -file, leaving quickfix visible. `:w` saves; `:CommentDelete` deletes the comment in an open card, even if you -focus the list again. `` closes a clean card; unsaved edits need `:w` or -`:q!`. an orphan has no line to jump to, so its editor returns to the list. +file, leaving quickfix visible. `:w` saves; `:CommentDelete` deletes the +comment under the quickfix cursor, or the open card when outside the list. +`` closes a clean card; unsaved edits need `:w` or `:q!`. an orphan has no +line to jump to, so its editor returns to the list. give `:CommentList` an argument to fuzzy match text, snippet or path: ```vim @@ -122,8 +123,8 @@ any quickfix viewer works: comments stay when you close a buffer (`:bd`, `:bw`). open the same source again and they'll pick up where they left off if the text is still on the same -lines. delete one with `:CommentDelete` while its card is open, or save its -comment empty. `:CommentClear` deletes them all. to keep them after exiting neovim, +lines. delete one from the built-in quickfix list with `:CommentDelete`, from +its open card, or by saving its comment empty. `:CommentClear` deletes them all. to keep them after exiting neovim, export them to a file. the signs share the sign column with plugins like gitsigns, and can cover their @@ -131,8 +132,8 @@ signs. `:CommentToggle` hides ours when you need to see theirs. if the lines a comment is on are deleted, it becomes an orphan. orphans are listed last in `:CommentList` and in an "Orphaned" section of the export. undo -restores them while the buffer is open. delete one with `:CommentDelete` -while its card is open. +restores them while the buffer is open. delete one from the built-in quickfix +list with `:CommentDelete`, or from its open card. ## colors @@ -160,7 +161,7 @@ vim.api.nvim_set_hl(0, "ScratchCommentBackdrop", { bg = "NONE" }) local scratch = require("scratch_comments") scratch.add(start_line, end_line) -- default: the cursor line -scratch.delete() -- comment in the open card +scratch.delete() -- quickfix cursor row or open card scratch.list() scratch.render(format) -- "markdown" (default) or "json" scratch.export(format, in_buffer) -- copy, or open in a scratch buffer diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index f08b0ed..e6b18dd 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -65,10 +65,12 @@ Requires Neovim 0.12 or newer. list only. *:CommentDelete* -:CommentDelete Delete the comment shown in an open card, whether - focus is in the card or the built-in quickfix list. - With no card open, nothing is deleted. Save or discard - unsaved edits first. +:CommentDelete In the built-in quickfix list opened by |:CommentList|, + delete the comment under the cursor without opening + its card. Elsewhere, delete the comment in the open + card. A different quickfix list is never a target. + Save or discard unsaved edits in the targeted card + first. *:CommentExport* :CommentExport[!] [format] @@ -115,7 +117,8 @@ orphan). A completed ordinary file reload with mismatched text also makes a parked comment inactive. Inactive comments are listed last in |:CommentList| and in an "Orphaned" section of the export; |undo| restores an extmark whose buffer is still present. Delete an inactive comment with |:CommentDelete| -while its card is open, or open its card with and save it empty. +from the built-in quickfix list or its open card, or open it with and +save it empty. In tmux you may need to set the clipboard to OSC 52 yourself: >lua vim.g.clipboard = "osc52" diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index a6150d8..92b29e3 100644 --- a/lua/scratch_comments/init.lua +++ b/lua/scratch_comments/init.lua @@ -17,7 +17,7 @@ M.render = export.render M.export = export.export M.list = list.open -M.delete = list.delete_open +M.delete = list.delete_selected -- Kept so configs that call it keep working; the commands need no setup. function M.setup() end diff --git a/lua/scratch_comments/list.lua b/lua/scratch_comments/list.lua index fa3dc7d..793de39 100644 --- a/lua/scratch_comments/list.lua +++ b/lua/scratch_comments/list.lua @@ -269,17 +269,26 @@ refresh = function(id, index, expected_id) vim.api.nvim_win_set_cursor(qf.winid, { row, 0 }) end --- Delete only the comment displayed in an open editor, never one inferred --- from the cursor in a file or the selected quickfix row. -function M.delete_open() +-- From our quickfix list, use the cursor row (not the last-jumped quickfix +-- index). Elsewhere, an open card identifies the comment to delete. +function M.delete_selected() local open = window.current() - local index = selected() - local id = open and open.id - if not id then - notify.warn("No comment card open") + local index, selected_id = selected() + if vim.bo.buftype == "quickfix" and not index then + notify.warn("Not a Scratch Comments list") return end - if open and vim.bo[vim.api.nvim_win_get_buf(open.comment_win)].modified then + local id + if index then + id = selected_id + else + id = open and open.id + end + if not id then + notify.warn(index and "This comment is gone" or "No comment card open") + return + end + if open and open.id == id and vim.bo[vim.api.nvim_win_get_buf(open.comment_win)].modified then notify.warn("Save or discard the comment first") return end @@ -295,9 +304,9 @@ function M.delete_open() elseif open and open.on_saved then open.on_saved() end - if open then + if open and open.id == id then if index then - -- Deleting from the list leaves focus there, even if its card was focused. + -- A command run from the list should leave focus there. window.close() else vim.api.nvim_win_close(open.comment_win, true) diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index 9749352..f18d034 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -310,6 +310,24 @@ describe("the comment list manager", function() vim.cmd.cclose() end) + it("deletes a filtered orphan directly from quickfix", function() + vim.cmd("1d") + helpers.text_changed() + vim.cmd("CommentList 1") + local qf_win = vim.api.nvim_get_current_win() + expect.equality(#vim.fn.getqflist(), 1) + expect.equality( + vim.fn.getqflist()[1].user_data.scratch_comments_id, + vim.iter(store.all()):find(function(comment) + return comment.comment == "on a" + end).id + ) + vim.cmd.CommentDelete() + expect.equality(#store.all(), 1) + expect.equality(store.all()[1].comment, "on c") + expect.equality(vim.api.nvim_win_is_valid(qf_win), false) + end) + it("deletes an inactive comment from the list", function() vim.cmd.cclose() vim.api.nvim_set_current_buf(source) diff --git a/tests/features/show_delete_spec.lua b/tests/features/show_delete_spec.lua index 90db5ac..8d937d0 100644 --- a/tests/features/show_delete_spec.lua +++ b/tests/features/show_delete_spec.lua @@ -14,11 +14,24 @@ before_each(function() end) describe(":CommentDelete", function() - it("does not delete a merely selected quickfix entry", function() - vim.api.nvim_win_set_cursor(0, { 2, 0 }) + it("deletes the cursor row without opening a card or jumping", function() + local qf_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) + expect.equality(vim.fn.getqflist({ idx = 0 }).idx, 1) vim.cmd.CommentDelete() - expect.equality(#store.all(), 2) - expect.equality(helpers.notified()[#helpers.notified()], "No comment card open") + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(#store.all(), 1) + expect.equality(views.anchored()[1].comment, "on one") + expect.equality(#vim.fn.getqflist(), 1) + end) + + it("closes quickfix after deleting its last row", function() + local qf_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) + vim.cmd.CommentDelete() + vim.cmd.CommentDelete() + expect.equality(#store.all(), 0) + expect.equality(vim.api.nvim_win_is_valid(qf_win), false) end) it("deletes the focused card by its ID", function() @@ -29,6 +42,32 @@ describe(":CommentDelete", function() expect.equality(views.anchored()[1].comment, "on one to three") end) + it("deletes the cursor row rather than a different open card", function() + local qf_win = vim.api.nvim_get_current_win() + vim.cmd("normal \r") + local open = assert(require("scratch_comments.ui.window").current()) + vim.api.nvim_set_current_win(qf_win) + vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) + vim.cmd.CommentDelete() + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(views.anchored()[1].comment, "on one") + expect.equality(require("scratch_comments.ui.window").current(), open) + end) + + it("keeps another open card's unsaved edits when deleting a quickfix row", function() + local qf_win = vim.api.nvim_get_current_win() + vim.cmd("normal \r") + local card_win = vim.api.nvim_get_current_win() + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) + vim.api.nvim_set_current_win(qf_win) + vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) + vim.cmd.CommentDelete() + expect.equality(views.anchored()[1].comment, "on one") + expect.equality(vim.bo[vim.api.nvim_win_get_buf(card_win)].modified, true) + vim.api.nvim_set_current_win(card_win) + vim.cmd("q!") + end) + it("keeps quickfix focus when invoked there while an editor is open", function() local qf_win = vim.api.nvim_get_current_win() vim.cmd("normal \r") @@ -39,6 +78,29 @@ describe(":CommentDelete", function() expect.equality(#store.all(), 1) end) + it("does not use the open card when another quickfix list replaces ours", function() + vim.cmd("normal \r") + local qf_win = vim.fn.getqflist({ winid = 0 }).winid + vim.fn.setqflist({}, " ", { items = { { filename = "other.lua", lnum = 1, text = "other" } } }) + vim.api.nvim_set_current_win(qf_win) + vim.cmd.CommentDelete() + expect.equality(#store.all(), 2) + expect.equality(helpers.notified()[#helpers.notified()], "Not a Scratch Comments list") + end) + + it("does not use an open card when the quickfix row is stale", function() + local qf_win = vim.api.nvim_get_current_win() + vim.cmd("normal \r") + local items = vim.fn.getqflist() + items[2].user_data = {} + vim.fn.setqflist({}, "r", { items = items }) + vim.api.nvim_set_current_win(qf_win) + vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) + vim.cmd.CommentDelete() + expect.equality(#store.all(), 2) + expect.equality(helpers.notified()[#helpers.notified()], "This comment is gone") + end) + it("does nothing after a card has closed", function() vim.cmd("normal \r") vim.cmd.quit() @@ -47,6 +109,20 @@ describe(":CommentDelete", function() expect.equality(helpers.notified()[#helpers.notified()], "No comment card open") end) + it("protects unsaved changes when its quickfix row is selected", function() + local qf_win = vim.api.nvim_get_current_win() + vim.cmd("normal \r") + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) + vim.api.nvim_set_current_win(qf_win) + vim.cmd.CommentDelete() + expect.equality(#store.all(), 2) + expect.equality(helpers.notified()[#helpers.notified()], "Save or discard the comment first") + vim.api.nvim_set_current_win( + assert(require("scratch_comments.ui.window").current()).comment_win + ) + vim.cmd("q!") + end) + it("protects unsaved changes", function() vim.cmd("normal \r") vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) -- 2.54.0 From 3baf51a14f03a4528f467a5e830e36518db88426 Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 30 Sep 2026 07:57:10 -0700 Subject: [PATCH 13/13] Separate quickfix navigation from comment editing --- README.md | 15 +- doc/scratch-comments.txt | 21 ++- lua/scratch_comments/actions.lua | 22 ++- lua/scratch_comments/comments.lua | 3 + lua/scratch_comments/list.lua | 171 ++++++++++-------- plugin/scratch-comments.lua | 4 +- tests/features/anchoring_spec.lua | 2 - tests/features/list_export_spec.lua | 268 ++++++++++++++++++++++++---- tests/features/show_delete_spec.lua | 22 +-- tests/features/window_spec.lua | 9 +- 10 files changed, 396 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index 0c75f96..240c6f3 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ it, pipe it to a command, or edit it: | Command | Does | | --- | --- | -| `:Comment` | Add a comment on the current line or range, or edit one already on that exact range | +| `:Comment` | Add or edit a comment in a file; in the built-in comment list, open the cursor row's card | | `:CommentList [filter]` | Put comments in the quickfix list and open it, fuzzy matching `filter` | | `:CommentDelete` | Delete the cursor row in the built-in quickfix list, or the open card elsewhere | | `:CommentExport[!] [format]` | Copy all comments to the clipboard as `markdown` (default) or `json`. `!` opens them in a scratch buffer | @@ -99,12 +99,13 @@ vim.keymap.set("n", "cx", "CommentExport", { desc = "Copy comme `:CommentList` puts all your comments in the quickfix list. move through them with `j`/`k` without moving the source cursor or opening a card. `` jumps -to the source when the comment has a location, then opens its card over the -file, leaving quickfix visible. `:w` saves; `:CommentDelete` deletes the -comment under the quickfix cursor, or the open card when outside the list. -`` closes a clean card; unsaved edits need `:w` or `:q!`. an orphan has no -line to jump to, so its editor returns to the list. -give `:CommentList` an argument to fuzzy match text, snippet or path: +to the source without opening a card. `:Comment` opens the comment under the +quickfix cursor for editing without jumping; its card stays over the file area +so the list remains visible. `:w` saves; `:CommentDelete` deletes the comment +under the quickfix cursor, or the open card when outside the list. `` +closes a clean card; unsaved edits need `:w` or `:q!`. an orphan has no line to +jump to, but `:Comment` still opens its card. give `:CommentList` an argument to +fuzzy match text, snippet or path: ```vim :CommentList typo diff --git a/doc/scratch-comments.txt b/doc/scratch-comments.txt index e6b18dd..a4772ec 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -35,7 +35,9 @@ Requires Neovim 0.12 or newer. (|v|) comments on just the selected text. A linewise one (|V|) comments on whole lines. If that exact text already has a comment, open it for editing instead of - adding a duplicate. + adding a duplicate. In the built-in quickfix list + opened by |:CommentList|, open the comment under the + cursor for editing without jumping to its source. A window opens over the file: the lines you are commenting on at the top, your comment below. @@ -52,17 +54,18 @@ Requires Neovim 0.12 or newer. in their text, snippet or path (see |matchfuzzy()|). Moving through the built-in list does not move the source cursor or open a card. jumps to the - comment's source location and opens its card over the - file, leaving quickfix visible. It starts in normal - mode. An orphan has no location; - edits it without a jump. |:w| saves; saving it + comment's source location without opening a card. + For an orphan there is no location to jump to; the + list stays open. |:Comment| opens the cursor row's + card for editing without moving the source cursor. + The card opens in normal mode. |:w| saves; saving it empty deletes it. closes a clean card from the list or the card; unsaved changes require |:w| or |:q!|. With no card open, does nothing. Other quickfix front-ends (trouble.nvim, nvim-bqf, Snacks.picker.qflist, :Telescope quickfix) can show - the entries; and belong to the built-in - list only. + the entries; the cursor-row commands and / + belong to the built-in list only. *:CommentDelete* :CommentDelete In the built-in quickfix list opened by |:CommentList|, @@ -117,8 +120,8 @@ orphan). A completed ordinary file reload with mismatched text also makes a parked comment inactive. Inactive comments are listed last in |:CommentList| and in an "Orphaned" section of the export; |undo| restores an extmark whose buffer is still present. Delete an inactive comment with |:CommentDelete| -from the built-in quickfix list or its open card, or open it with and -save it empty. +from the built-in quickfix list or its open card, or open it with |:Comment| +and save it empty. In tmux you may need to set the clipboard to OSC 52 yourself: >lua vim.g.clipboard = "osc52" diff --git a/lua/scratch_comments/actions.lua b/lua/scratch_comments/actions.lua index 0d9b981..b63beef 100644 --- a/lua/scratch_comments/actions.lua +++ b/lua/scratch_comments/actions.lua @@ -96,8 +96,16 @@ local function edit_new(bufnr, file_path, where) ---@type ScratchComment? local added ---@type ScratchCommentWindow - local record = - { bufnr = bufnr, line = where.start_line, source_win = vim.api.nvim_get_current_win() } + local record = { + bufnr = bufnr, + line = where.start_line, + source_win = vim.api.nvim_get_current_win(), + on_saved = function() + if added then + require("scratch_comments.list").refresh_comment(added.id) + end + end, + } window.open({ title = location.title(file_path, where), context = views.text(bufnr, where.start_line, where.end_line, where.start_col, where.end_col), @@ -128,6 +136,12 @@ end ---@param use_selection? boolean function M.comment(start_line, end_line, use_selection) local bufnr = vim.api.nvim_get_current_buf() + if vim.bo[bufnr].buftype == "quickfix" then + if not require("scratch_comments.list").edit_selected() then + notify.warn("Not a Scratch Comments list") + end + return + end local name = vim.api.nvim_buf_get_name(bufnr) if name == "" then notify.warn("Save the buffer before commenting on it") @@ -146,7 +160,9 @@ function M.comment(start_line, end_line, use_selection) local existing = find_anchor(bufnr, where.start_line, where.end_line, where.start_col, where.end_col) if existing then - M.show(existing, vim.api.nvim_get_current_win()) + M.show(existing, vim.api.nvim_get_current_win(), nil, function() + require("scratch_comments.list").refresh_comment(existing.id) + end) return end diff --git a/lua/scratch_comments/comments.lua b/lua/scratch_comments/comments.lua index 053ad91..d6d91c9 100644 --- a/lua/scratch_comments/comments.lua +++ b/lua/scratch_comments/comments.lua @@ -152,14 +152,17 @@ function M.attach_buffer(bufnr, definitive) changed = true elseif definitive then comment.state = "inactive" + changed = true end elseif definitive then comment.state = "inactive" + changed = true end end end if changed then M.redraw(bufnr) + require("scratch_comments.list").source_updated(bufnr) end end diff --git a/lua/scratch_comments/list.lua b/lua/scratch_comments/list.lua index 793de39..f2a4258 100644 --- a/lua/scratch_comments/list.lua +++ b/lua/scratch_comments/list.lua @@ -1,5 +1,4 @@ local actions = require("scratch_comments.actions") -local card = require("scratch_comments.ui.card") local comments = require("scratch_comments.comments") local store = require("scratch_comments.model.store") local notify = require("scratch_comments.ui.notify") @@ -98,74 +97,46 @@ local function jump() notify.warn("This comment is gone") return end + local entry = vim.iter(views.anchored()):find(function(view) + return view.id == id + end) + if not entry then + notify.warn("This comment has no source location; use :Comment to edit it") + return + end if not window.close() then notify.warn("Save or discard the comment first") return end local qf_win = vim.api.nvim_get_current_win() - local entry = vim.iter(views.anchored()):find(function(view) - return view.id == id - end) or vim.iter(views.orphans()):find(function(view) - return view.id == id + local ok = pcall(function() + vim.cmd(index .. "cc") end) - if not entry then - notify.warn("This comment is gone") + if not ok or vim.api.nvim_buf_get_name(0) ~= comment.source_name then + notify.warn("Could not open the comment source") return end - local owner = qf_win - local bufnr, win - if entry.start_line then - local ok = pcall(function() - vim.cmd(index .. "cc") - end) - if ok and vim.api.nvim_buf_get_name(0) == comment.source_name then - bufnr, win = vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() - owner = win - else - notify.warn("Could not open the comment source") - end - end + local bufnr, win = vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() local expected_id = list_id - if bufnr then - -- A completed read may invalidate a parked anchor while :cc opens it. - local resolved = vim.iter(views.orphans()):find(function(orphan) - return orphan.id == id - end) - if resolved then - entry = resolved - bufnr, win, owner = nil, nil, qf_win + local function return_to_manager() + if vim.api.nvim_win_is_valid(qf_win) and vim.fn.getqflist({ id = 0 }).id == expected_id then vim.api.nvim_set_current_win(qf_win) + refresh(id, index, expected_id) end end - actions.show(entry, owner, function() - vim.schedule(function() - if owner == qf_win and vim.fn.getqflist({ id = 0 }).id ~= expected_id then - return - end - if vim.api.nvim_win_is_valid(owner) then - vim.api.nvim_set_current_win(owner) - end - end) - end, function() - refresh(id, index, expected_id) - end) - if not bufnr or not win then + -- Loading a parked source can reveal that its saved location is gone. + if vim.iter(views.orphans()):find(function(orphan) + return orphan.id == id + end) then + return_to_manager() + notify.warn("This comment no longer has a location; use :Comment to edit it") return end local function position() if not vim.api.nvim_win_is_valid(win) or vim.api.nvim_win_get_buf(win) ~= bufnr then return true end - local active = window.current() - if - vim.api.nvim_get_current_win() ~= win - and not ( - active - and active.id == id - and active.source_win == win - and vim.api.nvim_get_current_win() == active.comment_win - ) - then + if vim.api.nvim_get_current_win() ~= win then return true end local current = by_id(id) @@ -180,13 +151,8 @@ local function jump() return true end if current.state == "inactive" then - local open = window.current() - if open and open.id == id and open.update then - open.update(card.of_orphan(current)) - open.source_win, owner = qf_win, qf_win - refresh(id, index, expected_id) - end - notify.warn("This comment no longer has a location") + return_to_manager() + notify.warn("This comment no longer has a location; use :Comment to edit it") return true end return false @@ -197,18 +163,16 @@ local function jump() -- An async BufReadCmd may fill the buffer after :cc has already moved the -- cursor. Keep one listener per buffer even if the user jumps repeatedly. pending_jump[bufnr] = position - local open = window.current() - if open then - vim.api.nvim_create_autocmd("BufLeave", { - buffer = vim.api.nvim_win_get_buf(open.comment_win), - once = true, - callback = function() - if pending_jump[bufnr] == position then - pending_jump[bufnr] = nil - end - end, - }) - end + -- A delayed read must not reposition a source the user has left and + -- returned to in the meantime. + vim.api.nvim_create_autocmd("WinLeave", { + once = true, + callback = function() + if pending_jump[bufnr] == position then + pending_jump[bufnr] = nil + end + end, + }) -- BufReadPost reattachment runs on the next scheduled turn. Recheck after -- it, even if the file was fully populated before our on_lines listener. vim.schedule(function() @@ -269,6 +233,71 @@ refresh = function(id, index, expected_id) vim.api.nvim_win_set_cursor(qf.winid, { row, 0 }) end +-- A card opened from a source window may also change a visible manager list. +---@param id? string +function M.refresh_comment(id) + local qf = vim.fn.getqflist({ id = 0, winid = 0 }) + if list_id and qf.id == list_id and qf.winid ~= 0 then + local row = vim.api.nvim_win_get_cursor(qf.winid)[1] + local item = vim.fn.getqflist({ items = 1 }).items[row] + local selected_id = item and item.user_data and item.user_data.scratch_comments_id + refresh(selected_id or id or "", row, list_id) + end +end + +-- Recheck a pending navigation after reattachment, which can happen after +-- the last on_lines notification from an asynchronous source. +---@param bufnr integer +function M.source_updated(bufnr) + M.refresh_comment() + local position = pending_jump[bufnr] + if position then + vim.schedule(function() + if pending_jump[bufnr] == position and position() then + pending_jump[bufnr] = nil + end + end) + end +end + +-- Open the card for the cursor row without jumping to its source. +---@return boolean handled true if this is our quickfix list +function M.edit_selected() + local index, id = selected() + if not index then + return false + end + if not id or not by_id(id) then + notify.warn("This comment is gone") + return true + end + local entry = vim.iter(views.anchored()):find(function(view) + return view.id == id + end) or vim.iter(views.orphans()):find(function(view) + return view.id == id + end) + if not entry then + notify.warn("This comment is gone") + return true + end + if not window.close() then + notify.warn("Save or discard the comment first") + return true + end + local qf_win = vim.api.nvim_get_current_win() + local expected_id = list_id + actions.show(entry, qf_win, function() + vim.schedule(function() + if vim.fn.getqflist({ id = 0 }).id == expected_id and vim.api.nvim_win_is_valid(qf_win) then + vim.api.nvim_set_current_win(qf_win) + end + end) + end, function() + refresh(id, index, expected_id) + end) + return true +end + -- From our quickfix list, use the cursor row (not the last-jumped quickfix -- index). Elsewhere, an open card identifies the comment to delete. function M.delete_selected() @@ -346,7 +375,7 @@ function M.open(filter) return end list_bufnr = vim.api.nvim_get_current_buf() - vim.keymap.set("n", "", jump, { buffer = list_bufnr, desc = "Jump and edit comment" }) + vim.keymap.set("n", "", jump, { buffer = list_bufnr, desc = "Jump to comment source" }) vim.keymap.set("n", "", escape, { buffer = list_bufnr, desc = "Dismiss comment card" }) return true end diff --git a/plugin/scratch-comments.lua b/plugin/scratch-comments.lua index cf4fb3f..8fc41b6 100644 --- a/plugin/scratch-comments.lua +++ b/plugin/scratch-comments.lua @@ -11,11 +11,11 @@ local command = vim.api.nvim_create_user_command command("Comment", function(ctx) require("scratch_comments.actions").comment(ctx.line1, ctx.line2, ctx.range == 2) -end, { range = true, desc = "Comment on the current line or range" }) +end, { range = true, desc = "Add or edit a source comment, or edit the selected quickfix comment" }) command("CommentDelete", function() scratch().delete() -end, { desc = "Delete the comment in the open card" }) +end, { desc = "Delete the selected quickfix comment or open card" }) command("CommentList", function(ctx) scratch().list(ctx.args ~= "" and ctx.args or nil) diff --git a/tests/features/anchoring_spec.lua b/tests/features/anchoring_spec.lua index f198e5f..3e7966c 100644 --- a/tests/features/anchoring_spec.lua +++ b/tests/features/anchoring_spec.lua @@ -129,7 +129,6 @@ describe("an orphan", function() it("can be deleted from the comment list", function() vim.cmd.CommentList() - vim.cmd("normal \r") vim.cmd.CommentDelete() expect.equality(#views.orphans(), 0) end) @@ -237,7 +236,6 @@ describe("a file's comments", function() expect.equality(#views.orphans(), 1) expect.equality(#views.anchored(), 0) vim.cmd.CommentList() - vim.cmd("normal \r") vim.cmd.CommentDelete() expect.equality(#store.all(), 0) end) diff --git a/tests/features/list_export_spec.lua b/tests/features/list_export_spec.lua index f18d034..8719733 100644 --- a/tests/features/list_export_spec.lua +++ b/tests/features/list_export_spec.lua @@ -108,7 +108,7 @@ describe("the comment list manager", function() it("edits a comment in the same card, then refreshes the list", function() vim.cmd("CommentList") local id = vim.fn.getqflist()[1].user_data.scratch_comments_id - vim.cmd("normal \r") + vim.cmd.Comment() expect.equality(helpers.text(), "on a") expect.equality(vim.bo.modifiable, true) vim.api.nvim_buf_set_lines(0, 0, -1, false, { "revised" }) @@ -116,7 +116,7 @@ describe("the comment list manager", function() expect.equality(vim.fn.getqflist()[1].text, "revised") vim.cmd.quit() vim.wait(0) - expect.equality(vim.api.nvim_get_current_buf(), source) + expect.equality(vim.bo.buftype, "quickfix") expect.equality(vim.fn.getqflist()[1].text, "revised") expect.equality(vim.fn.getqflist()[1].user_data.scratch_comments_id, id) vim.cmd.cclose() @@ -124,7 +124,7 @@ describe("the comment list manager", function() it("doesn't reopen a list the user closed while editing", function() vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() vim.cmd.cclose() vim.cmd.quit() vim.wait(0) @@ -140,7 +140,7 @@ describe("the comment list manager", function() expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 1) vim.cmd("normal \r") expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 3) - vim.cmd.quit() + expect.equality(#helpers.every_float(), 0) end) it("doesn't return an orphan editor to another plugin's quickfix list", function() @@ -149,7 +149,7 @@ describe("the comment list manager", function() local file_win = vim.api.nvim_get_current_win() vim.cmd.CommentList() vim.api.nvim_win_set_cursor(0, { 2, 0 }) - vim.cmd("normal \r") + vim.cmd.Comment() vim.fn.setqflist({}, " ", { items = { { filename = "other.lua", lnum = 1, text = "other" } } }) vim.cmd.quit() vim.api.nvim_set_current_win(file_win) @@ -159,7 +159,7 @@ describe("the comment list manager", function() it("keeps focus on an export replacing a manager-opened card", function() vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() vim.cmd("CommentExport!") vim.wait(0) expect.equality(vim.bo.buftype, "nofile") @@ -170,7 +170,7 @@ describe("the comment list manager", function() it("edits a comment whose source was wiped", function() vim.cmd("bwipeout! " .. source) vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() expect.equality(helpers.text(), "on a") vim.api.nvim_buf_set_lines(0, 0, -1, false, { "revised parked" }) vim.cmd("wq") @@ -180,7 +180,7 @@ describe("the comment list manager", function() vim.cmd.cclose() end) - it("opens an orphan for editing without a guessed jump", function() + it("does not guess a jump for an orphan; :Comment opens its card", function() vim.cmd("1d") helpers.text_changed() vim.cmd.CommentList() @@ -188,6 +188,13 @@ describe("the comment list manager", function() vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) local qf_win = vim.api.nvim_get_current_win() vim.cmd("normal \r") + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(#helpers.every_float(), 0) + expect.equality( + helpers.notified()[#helpers.notified()], + "This comment has no source location; use :Comment to edit it" + ) + vim.cmd.Comment() expect.equality(helpers.text(), "on a") local dim = vim.iter(helpers.every_float()):find(function(win) return vim.w[win].scratch_comments_backdrop @@ -208,7 +215,7 @@ describe("the comment list manager", function() expect.equality(vim.bo.buftype, "nofile") vim.cmd.CommentList() vim.api.nvim_win_set_cursor(0, { 2, 0 }) - vim.cmd("normal \r") + vim.cmd.Comment() local dim = vim.iter(helpers.every_float()):find(function(win) return vim.w[win].scratch_comments_backdrop end) @@ -224,7 +231,7 @@ describe("the comment list manager", function() local file_win = vim.fn.bufwinid(vim.api.nvim_buf_get_name(source)) vim.api.nvim_win_close(file_win, true) vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) - vim.cmd("normal \r") + vim.cmd.Comment() local dim = vim.iter(helpers.every_float()):find(function(win) return vim.w[win].scratch_comments_backdrop end) @@ -247,7 +254,7 @@ describe("the comment list manager", function() vim.o.winheight = 12 vim.o.winminheight = 12 vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) - vim.cmd("normal \r") + vim.cmd.Comment() end) vim.o.winminheight = min_height vim.o.winheight = preferred @@ -267,7 +274,7 @@ describe("the comment list manager", function() vim.cmd.CommentList() vim.api.nvim_win_set_cursor(0, { 2, 0 }) vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) - vim.cmd("normal \r") + vim.cmd.Comment() expect.equality(helpers.text(), "on a") vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) vim.cmd.write() @@ -281,7 +288,7 @@ describe("the comment list manager", function() it("deletes the opened comment and keeps the filtered list", function() vim.cmd("CommentList export-fixture") local removed = vim.fn.getqflist()[1].user_data.scratch_comments_id - vim.cmd("normal \r") + vim.cmd.Comment() vim.cmd.CommentDelete() expect.equality(#store.all(), 1) expect.no_equality(store.all()[1].id, removed) @@ -303,7 +310,7 @@ describe("the comment list manager", function() vim.cmd("bwipeout! " .. source) expect.equality(#store.all(), 2) vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() vim.cmd.CommentDelete() expect.equality(#store.all(), 1) expect.equality(store.all()[1].comment, "on c") @@ -339,13 +346,32 @@ describe("the comment list manager", function() return item.text:find("[orphaned]", 1, true) ~= nil end) vim.api.nvim_win_set_cursor(0, { index, 0 }) - vim.cmd("normal \r") vim.cmd.CommentDelete() expect.equality(#store.all(), 1) expect.equality(store.all()[1].comment, "on c") vim.cmd.cclose() end) + it("does not open a card from another plugin's quickfix list", function() + vim.cmd.CommentList() + vim.fn.setqflist({}, " ", { items = { { filename = "other.lua", lnum = 1, text = "other" } } }) + vim.cmd.Comment() + expect.equality(#helpers.every_float(), 0) + expect.equality(helpers.notified()[#helpers.notified()], "Not a Scratch Comments list") + vim.cmd.cclose() + end) + + it("does not open a card for a stale quickfix row", function() + vim.cmd.CommentList() + local items = vim.fn.getqflist() + items[1].user_data = {} + vim.fn.setqflist({}, "r", { items = items }) + vim.cmd.Comment() + expect.equality(#helpers.every_float(), 0) + expect.equality(helpers.notified()[#helpers.notified()], "This comment is gone") + vim.cmd.cclose() + end) + it("does not delete another plugin's quickfix entry", function() vim.cmd.CommentList() vim.fn.setqflist({}, " ", { items = { { filename = "other.lua", lnum = 1, text = "other" } } }) @@ -396,6 +422,110 @@ describe("the comment list manager", function() expect.equality(vim.api.nvim_win_get_cursor(0)[1], 1) end) + it("returns to quickfix after an asynchronous source becomes an orphan", function() + local url = "review-test:///orphan.lua" + local pending + vim.api.nvim_create_autocmd("BufReadCmd", { + pattern = "review-test:///*", + once = true, + callback = function(args) + pending = args.buf + end, + }) + vim.cmd.enew() + vim.api.nvim_buf_set_name(0, url) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "first", "second" }) + vim.bo.bufhidden = "wipe" + vim.cmd("2Comment") + helpers.write("async orphan") + local id = store.all()[#store.all()].id + vim.cmd("enew!") + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + local index = vim.iter(vim.fn.getqflist()):enumerate():find(function(_, item) + return item.user_data.scratch_comments_id == id + end) + vim.api.nvim_win_set_cursor(qf_win, { index, 0 }) + vim.cmd("normal \r") + vim.api.nvim_buf_set_lines(pending, 0, -1, false, { "first", "changed" }) + vim.api.nvim_exec_autocmds("BufReadPost", { buffer = pending }) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, "inactive") + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(vim.fn.getqflist()[index].text, "[orphaned] async orphan") + expect.equality(#helpers.every_float(), 0) + end) + + local function jump_to_pending_source(url) + local pending + vim.api.nvim_create_autocmd("BufReadCmd", { + pattern = "review-test:///*", + once = true, + callback = function(args) + pending = args.buf + end, + }) + vim.cmd("enew!") + vim.api.nvim_buf_set_name(0, url) + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "first", "second" }) + vim.bo.bufhidden = "wipe" + vim.cmd("2Comment") + helpers.write("async orphan") + vim.cmd("enew!") + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + local id = store.all()[#store.all()].id + local index = vim.iter(vim.fn.getqflist()):enumerate():find(function(_, item) + return item.user_data.scratch_comments_id == id + end) + vim.api.nvim_win_set_cursor(qf_win, { index, 0 }) + vim.cmd("normal \r") + return pending, qf_win + end + + it("does not return to a closed list when a delayed read invalidates a location", function() + local pending, qf_win = jump_to_pending_source("review-test:///closed-list.lua") + local source_win = vim.api.nvim_get_current_win() + vim.cmd.cclose() + vim.api.nvim_buf_set_lines(pending, 0, -1, false, { "first", "changed" }) + vim.api.nvim_exec_autocmds("BufReadPost", { buffer = pending }) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, "inactive") + expect.equality(vim.api.nvim_win_is_valid(qf_win), false) + expect.equality(vim.api.nvim_get_current_win(), source_win) + end) + + it( + "does not enter a replacement quickfix list when a delayed read invalidates a location", + function() + local pending, qf_win = jump_to_pending_source("review-test:///replaced-list.lua") + local source_win = vim.api.nvim_get_current_win() + vim.fn.setqflist( + {}, + " ", + { items = { { filename = "other.lua", lnum = 1, text = "other" } } } + ) + vim.api.nvim_buf_set_lines(pending, 0, -1, false, { "first", "changed" }) + vim.api.nvim_exec_autocmds("BufReadPost", { buffer = pending }) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, "inactive") + expect.equality(vim.api.nvim_get_current_win(), source_win) + expect.equality(vim.api.nvim_win_is_valid(qf_win), true) + expect.equality(vim.fn.getqflist()[1].text, "other") + end + ) + + it("refreshes the list after leaving a delayed jump that becomes an orphan", function() + local pending, qf_win = jump_to_pending_source("review-test:///left-source.lua") + vim.api.nvim_set_current_win(qf_win) + vim.api.nvim_buf_set_lines(pending, 0, -1, false, { "first", "changed" }) + vim.api.nvim_exec_autocmds("BufReadPost", { buffer = pending }) + vim.wait(0) + expect.equality(store.all()[#store.all()].state, "inactive") + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(vim.fn.getqflist()[#vim.fn.getqflist()].text, "[orphaned] async orphan") + end) + it("doesn't move the source cursor after leaving an asynchronous jump", function() local url = "review-test:///late-cursor.lua" local pending @@ -430,7 +560,7 @@ describe("the comment list manager", function() expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 1) end) - it("shows an orphan rather than stale source lines after a mismatched reload", function() + it("does not navigate to stale source lines after a mismatched reload", function() vim.cmd.edit("tests/fixtures/lifecycle.txt") vim.api.nvim_buf_set_lines(0, 0, 1, false, { "unsaved source" }) vim.cmd.Comment() @@ -444,6 +574,11 @@ describe("the comment list manager", function() end) vim.api.nvim_win_set_cursor(0, { index, 0 }) vim.cmd("normal \r") + vim.wait(0) + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(#helpers.every_float(), 0) + expect.equality(vim.fn.getqflist()[index].text:find("[orphaned]", 1, true), 1) + vim.cmd.Comment() vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved edit" }) vim.wait(0) expect.equality(store.all()[#store.all()].state, "inactive") @@ -474,9 +609,9 @@ describe("the comment list manager", function() end vim.api.nvim_win_set_cursor(0, { index, 0 }) vim.cmd("normal \r") - vim.cmd.quit() vim.wait(0) expect.equality(vim.api.nvim_buf_get_name(0), store.all()[#store.all()].source_name) + expect.equality(#helpers.every_float(), 0) expect.equality(store.all()[#store.all()].state, nil) end) end) @@ -536,7 +671,7 @@ end) describe("one card at a time", function() it(":CommentList closes an open comment window without opening another", function() vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() expect.equality(#helpers.floats(), 2) vim.cmd("CommentList") expect.equality(#helpers.floats(), 0) @@ -545,16 +680,32 @@ describe("one card at a time", function() it(":CommentList keeps an unsaved comment, and says so", function() vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) vim.cmd("CommentList") expect.equality(helpers.text(), "unsaved") expect.equality(helpers.notified()[#helpers.notified()], "Save or discard the comment first") end) + it(":Comment keeps unsaved edits when selecting another row", function() + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + vim.cmd.Comment() + local card_win = vim.api.nvim_get_current_win() + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) + vim.api.nvim_set_current_win(qf_win) + vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) + vim.cmd.Comment() + expect.equality(#helpers.floats(), 2) + expect.equality(helpers.notified()[#helpers.notified()], "Save or discard the comment first") + expect.equality(helpers.text(vim.api.nvim_win_get_buf(card_win)), "unsaved") + vim.api.nvim_set_current_win(card_win) + vim.cmd("q!") + end) + it(":CommentExport! closes the comment window first", function() vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() vim.cmd("CommentExport!") expect.equality(#helpers.every_float(), 0) expect.equality(vim.bo.buftype, "nofile") @@ -565,6 +716,9 @@ describe("one card at a time", function() vim.cmd("wincmd k") vim.cmd("2Comment") expect.equality(#helpers.floats(), 2) + helpers.write("on b") + expect.equality(#vim.fn.getqflist(), 3) + expect.equality(vim.fn.getqflist()[2].text, "on b") end) end) @@ -580,19 +734,50 @@ describe("quickfix navigation", function() expect.equality(#helpers.every_float(), 0) end) - it("opens an editable card only after jumps to the source", function() + it("jumps on without opening a card", function() vim.cmd.CommentList() - expect.equality(#helpers.every_float(), 0) vim.cmd("normal j") vim.cmd("normal \r") + expect.equality(vim.api.nvim_get_current_buf(), source) + expect.equality(vim.api.nvim_win_get_cursor(0)[1], 3) + expect.equality(#helpers.every_float(), 0) + end) + + it("keeps the selected quickfix row when saving a card opened from source", function() + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) + vim.cmd("wincmd k") + vim.cmd.Comment() + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "revised from source" }) + vim.cmd.write() + expect.equality(vim.api.nvim_win_get_cursor(qf_win)[1], 2) + expect.equality(vim.fn.getqflist()[1].text, "revised from source") + end) + + it("refreshes the list when editing from the source after a jump", function() + vim.cmd.CommentList() + vim.cmd("normal \r") + expect.equality(#helpers.every_float(), 0) + vim.cmd.Comment() + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "revised from source" }) + vim.cmd.write() + expect.equality(vim.fn.getqflist()[1].text, "revised from source") + vim.cmd.quit() + end) + + it(":Comment edits the cursor row without moving the source cursor", function() + local source_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(source_win, { 1, 0 }) + vim.cmd.CommentList() + vim.cmd("normal j") + vim.cmd.Comment() expect.equality(helpers.text(), "on c") - expect.equality(vim.bo.modifiable, true) - expect.equality(#helpers.floats(), 2) - local dim = vim.tbl_filter(function(win) + expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 1) + local dim = vim.iter(helpers.every_float()):find(function(win) return vim.w[win].scratch_comments_backdrop - end, helpers.every_float()) - local source_win = require("scratch_comments.ui.window").current().source_win - local config = vim.api.nvim_win_get_config(dim[1]) + end) + local config = vim.api.nvim_win_get_config(dim) expect.equality(config.relative, "win") expect.equality(config.win, source_win) expect.equality(config.height, vim.api.nvim_win_get_height(source_win)) @@ -603,14 +788,33 @@ describe("quickfix navigation", function() ) vim.cmd.quit() vim.wait(0) - expect.equality(vim.api.nvim_get_current_buf(), source) - expect.equality(vim.api.nvim_win_get_cursor(0)[1], 3) + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 1) + end) + + it("does not jump away from unsaved card edits", function() + local source_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(source_win, { 1, 0 }) + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + vim.cmd.Comment() + local card_win = vim.api.nvim_get_current_win() + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) + vim.api.nvim_set_current_win(qf_win) + vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) + vim.cmd("normal \r") + expect.equality(vim.api.nvim_get_current_win(), qf_win) + expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 1) + expect.equality(helpers.text(vim.api.nvim_win_get_buf(card_win)), "unsaved") + expect.equality(helpers.notified()[#helpers.notified()], "Save or discard the comment first") + vim.api.nvim_set_current_win(card_win) + vim.cmd("q!") end) it("keeps another editing split visible behind the card", function() vim.cmd.vsplit() vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() local dim = vim.iter(helpers.every_float()):find(function(win) return vim.w[win].scratch_comments_backdrop end) @@ -630,7 +834,7 @@ describe("quickfix navigation", function() it("closes a clean editor on from the list, but preserves unsaved edits", function() vim.cmd.CommentList() local qf_win = vim.api.nvim_get_current_win() - vim.cmd("normal \r") + vim.cmd.Comment() local comment_buf = vim.api.nvim_get_current_buf() vim.api.nvim_set_current_win(qf_win) vim.api.nvim_buf_set_lines(comment_buf, 0, -1, false, { "unsaved" }) diff --git a/tests/features/show_delete_spec.lua b/tests/features/show_delete_spec.lua index 8d937d0..cb95019 100644 --- a/tests/features/show_delete_spec.lua +++ b/tests/features/show_delete_spec.lua @@ -35,7 +35,7 @@ describe(":CommentDelete", function() end) it("deletes the focused card by its ID", function() - vim.cmd("normal \r") + vim.cmd.Comment() vim.cmd.CommentDelete() vim.wait(0) expect.equality(#store.all(), 1) @@ -44,7 +44,7 @@ describe(":CommentDelete", function() it("deletes the cursor row rather than a different open card", function() local qf_win = vim.api.nvim_get_current_win() - vim.cmd("normal \r") + vim.cmd.Comment() local open = assert(require("scratch_comments.ui.window").current()) vim.api.nvim_set_current_win(qf_win) vim.api.nvim_win_set_cursor(qf_win, { 2, 0 }) @@ -56,7 +56,7 @@ describe(":CommentDelete", function() it("keeps another open card's unsaved edits when deleting a quickfix row", function() local qf_win = vim.api.nvim_get_current_win() - vim.cmd("normal \r") + vim.cmd.Comment() local card_win = vim.api.nvim_get_current_win() vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) vim.api.nvim_set_current_win(qf_win) @@ -70,7 +70,7 @@ describe(":CommentDelete", function() it("keeps quickfix focus when invoked there while an editor is open", function() local qf_win = vim.api.nvim_get_current_win() - vim.cmd("normal \r") + vim.cmd.Comment() vim.api.nvim_set_current_win(qf_win) vim.cmd.CommentDelete() vim.wait(0) @@ -79,7 +79,7 @@ describe(":CommentDelete", function() end) it("does not use the open card when another quickfix list replaces ours", function() - vim.cmd("normal \r") + vim.cmd.Comment() local qf_win = vim.fn.getqflist({ winid = 0 }).winid vim.fn.setqflist({}, " ", { items = { { filename = "other.lua", lnum = 1, text = "other" } } }) vim.api.nvim_set_current_win(qf_win) @@ -90,7 +90,7 @@ describe(":CommentDelete", function() it("does not use an open card when the quickfix row is stale", function() local qf_win = vim.api.nvim_get_current_win() - vim.cmd("normal \r") + vim.cmd.Comment() local items = vim.fn.getqflist() items[2].user_data = {} vim.fn.setqflist({}, "r", { items = items }) @@ -102,7 +102,7 @@ describe(":CommentDelete", function() end) it("does nothing after a card has closed", function() - vim.cmd("normal \r") + vim.cmd.Comment() vim.cmd.quit() vim.cmd.CommentDelete() expect.equality(#store.all(), 2) @@ -111,7 +111,7 @@ describe(":CommentDelete", function() it("protects unsaved changes when its quickfix row is selected", function() local qf_win = vim.api.nvim_get_current_win() - vim.cmd("normal \r") + vim.cmd.Comment() vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) vim.api.nvim_set_current_win(qf_win) vim.cmd.CommentDelete() @@ -124,7 +124,7 @@ describe(":CommentDelete", function() end) it("protects unsaved changes", function() - vim.cmd("normal \r") + vim.cmd.Comment() vim.api.nvim_buf_set_lines(0, 0, -1, false, { "unsaved" }) vim.cmd.CommentDelete() expect.equality(#store.all(), 2) @@ -134,7 +134,7 @@ end) describe("saving an empty comment", function() it("deletes the edited comment and closes its card", function() - vim.cmd("normal \r") + vim.cmd.Comment() vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) vim.cmd.write() vim.wait(0) @@ -144,7 +144,7 @@ describe("saving an empty comment", function() end) it("does not delete it when empty changes are discarded", function() - vim.cmd("normal \r") + vim.cmd.Comment() vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) vim.cmd("q!") expect.equality(#store.all(), 2) diff --git a/tests/features/window_spec.lua b/tests/features/window_spec.lua index 40b2d64..6ca3987 100644 --- a/tests/features/window_spec.lua +++ b/tests/features/window_spec.lua @@ -13,6 +13,7 @@ before_each(function() vim.api.nvim_win_set_cursor(0, { 1, 0 }) vim.cmd.CommentList() vim.cmd("normal \r") + vim.cmd("1,3Comment") end) after_each(function() @@ -208,7 +209,7 @@ describe("the comment window", function() vim.cmd("quit") local baseline = handlers() vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() expect.equality(handlers() > baseline, true) vim.cmd("quit") expect.equality(handlers(), baseline) @@ -235,21 +236,21 @@ describe("the border", function() it("is rounded when 'winborder' is not set", function() vim.o.winborder = "" vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() expect.equality(vim.api.nvim_win_get_config(0).border[1], "╭") end) it("follows 'winborder' when it is set", function() vim.o.winborder = "single" vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() expect.equality(vim.api.nvim_win_get_config(0).border[1], "┌") end) it("restores a custom border when a short source window grows again", function() vim.o.winborder = "single" vim.cmd.CommentList() - vim.cmd("normal \r") + vim.cmd.Comment() local record = assert(require("scratch_comments.ui.window").current()) ---@cast record { comment_win: integer, source_win: integer } vim.api.nvim_set_current_win(record.source_win) -- 2.54.0