diff --git a/README.md b/README.md index 0d4a871..240c6f3 100644 --- a/README.md +++ b/README.md @@ -49,21 +49,15 @@ 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!` 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. 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 @@ -83,12 +77,9 @@ 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 | -| `:CommentDelete` | Delete the comment at the cursor, or pick an orphaned one | +| `: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 | | `:CommentToggle [on\|off]` | Show or hide the comment signs | | `:CommentClear` | Delete every comment | @@ -100,23 +91,21 @@ 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. 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` without moving the source cursor or opening a card. `` jumps +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 @@ -133,21 +122,24 @@ 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 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 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 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 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 from the built-in quickfix +list with `:CommentDelete`, or from its open card. ## 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 @@ -170,10 +162,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() +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 @@ -199,8 +188,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 868fd86..a4772ec 100644 --- a/doc/scratch-comments.txt +++ b/doc/scratch-comments.txt @@ -15,10 +15,10 @@ 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 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. @@ -34,9 +34,12 @@ 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, open it for editing instead of + 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 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 @@ -45,47 +48,32 @@ 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. - - *: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. - - *: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 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. - The card goes away with the list. - Any quickfix front-end (trouble.nvim, nvim-bqf, + Moving through the built-in list does not move the + source cursor or open a card. jumps to the + 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 - it. + 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|, + 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] @@ -109,21 +97,31 @@ 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 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. ============================================================================== 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 |:CommentDelete| +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" @@ -144,8 +142,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" }) @@ -158,10 +156,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() + 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 @@ -171,7 +166,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 7287b13..b63beef 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") @@ -9,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 @@ -45,29 +40,49 @@ 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())) +---@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 ScratchCommentView +---@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() +function M.show(view, source_win, on_close, on_saved) + 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, + }) end -- Opens an editor for a comment that does not exist yet; it is created when @@ -76,12 +91,21 @@ 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 = paths.is_uri(file_path) and file_path + or paths.relative_path(paths.git_root(file_path), file_path) ---@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), @@ -89,19 +113,16 @@ 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({ bufnr = bufnr, comment = text, file_path = file_path, + source_name = file_path, relative_path = relative_path, }, where) record.id = added.id @@ -115,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") @@ -133,77 +160,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()) + M.show(existing, vim.api.nvim_get_current_win(), nil, function() + require("scratch_comments.list").refresh_comment(existing.id) + end) return end - edit_new(bufnr, 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 - ----@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 - 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 - -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 orphans = vim.tbl_filter(function(comment) - return comment.bufnr == bufnr - 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) + edit_new(bufnr, paths.is_uri(name) and name or vim.fn.fnamemodify(name, ":p"), where) end ---@param on? boolean diff --git a/lua/scratch_comments/comments.lua b/lua/scratch_comments/comments.lua index 4d44934..d6d91c9 100644 --- a/lua/scratch_comments/comments.lua +++ b/lua/scratch_comments/comments.lua @@ -1,26 +1,33 @@ 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") 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 +36,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 +57,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 +73,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 +86,84 @@ 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 + anchors.clear_all({ comment }) + 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 +---@param definitive? boolean true after a completed ordinary file read +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 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 + end + 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 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" + 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) + 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 + 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 ---@param on boolean @@ -89,10 +181,71 @@ 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.attach_buffer(args.buf, true) + 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 + 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() + vim.schedule(function() + if vim.api.nvim_buf_is_valid(bufnr) then + M.attach_buffer(bufnr) + end + end) + if + not paths.is_uri(name) + 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 + 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 paths.is_uri(name) or lines[1] ~= "" then + vim.schedule(function() + if vim.api.nvim_buf_is_valid(bufnr) then + M.attach_buffer(bufnr) + end + end) + end + end end, }) diff --git a/lua/scratch_comments/init.lua b/lua/scratch_comments/init.lua index e83b50d..92b29e3 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,22 +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.delete = actions.delete_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_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 da5ba03..f2a4258 100644 --- a/lua/scratch_comments/list.lua +++ b/lua/scratch_comments/list.lua @@ -1,5 +1,6 @@ -local card = require("scratch_comments.ui.card") -local preview = require("scratch_comments.ui.preview") +local actions = require("scratch_comments.actions") +local comments = require("scratch_comments.comments") +local store = require("scratch_comments.model.store") local notify = require("scratch_comments.ui.notify") local quickfix = require("scratch_comments.ui.quickfix") local window = require("scratch_comments.ui.window") @@ -7,6 +8,13 @@ local views = require("scratch_comments.model.views") local M = {} +local list_id +local list_bufnr +local active_filter +local pending_jump = {} +local listening = {} +local refresh + ---@param comment ScratchComment|ScratchCommentView ---@return string local function haystack(comment) @@ -15,12 +23,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 +37,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,29 +45,339 @@ local function matching(comments, filter) return matched end --- Every comment in the quickfix list, with the one under the cursor shown --- in a card above it. +---@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) + 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() + -- 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. + 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 + 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 ok = pcall(function() + vim.cmd(index .. "cc") + end) + 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 bufnr, win = vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() + local expected_id = list_id + 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 + -- 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 + if vim.api.nvim_get_current_win() ~= win 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 + return_to_manager() + notify.warn("This comment no longer has a location; use :Comment to edit it") + 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. Keep one listener per buffer even if the user jumps repeatedly. + pending_jump[bufnr] = position + -- 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() + if pending_jump[bufnr] == position and 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() + local current = pending_jump[bufnr] + if not current then + listening[bufnr] = nil + return true + end + vim.schedule(function() + 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 + +-- 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 +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 + 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 + 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 + +-- 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() + local open = window.current() + local index, selected_id = selected() + if vim.bo.buftype == "quickfix" and not index then + notify.warn("Not a Scratch Comments list") + return + end + 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 + local comment = by_id(id) + if not comment then + notify.warn("This comment is gone") + return + 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 and open.id == id then + if index then + -- A command run from the list should leave focus there. + window.close() + else + vim.api.nvim_win_close(open.comment_win, true) + end + end +end + +local function escape() + if window.current() and not window.close() then + notify.warn("Save with :w, or discard with :q!") + end +end + +-- 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 notify.warn("Save or discard the comment first") return end - local anchored, orphans = views.anchored(), views.orphans() - if filter and filter ~= "" then - anchored, orphans = matching(anchored, filter), matching(orphans, filter) + for bufnr in pairs(pending_jump) do + pending_jump[bufnr] = nil end - local listed = vim.list_extend(vim.list_slice(anchored), orphans) - quickfix.list(anchored, orphans, function(index) - local entry = index and listed[index] - if not entry then - preview.close() - elseif entry.snippet then - preview.show(card.of(entry)) - else - preview.show(card.of_orphan(entry)) + active_filter = filter + local anchored, orphans = filtered(filter) + if #anchored + #orphans == 0 then + if list_id and vim.fn.getqflist({ id = 0 }).id == list_id then + vim.fn.setqflist({}, "r", { items = {} }) + vim.cmd.cclose() end - end) + list_id, list_bufnr = nil, nil + notify.info("No comments") + return false + end + list_id = quickfix.list(anchored, orphans) + 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 source" }) + vim.keymap.set("n", "", escape, { buffer = list_bufnr, desc = "Dismiss comment card" }) + return true end return M 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/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/navigate.lua b/lua/scratch_comments/navigate.lua deleted file mode 100644 index feabb6c..0000000 --- a/lua/scratch_comments/navigate.lua +++ /dev/null @@ -1,92 +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) - 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/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/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/card.lua b/lua/scratch_comments/ui/card.lua index 2d918e5..33a6d65 100644 --- a/lua/scratch_comments/ui/card.lua +++ b/lua/scratch_comments/ui/card.lua @@ -6,11 +6,14 @@ local M = {} ---@param view ScratchCommentView ---@return ScratchFrame function M.of(view) - local in_file = views.in_buffer(view.bufnr) + 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"), - 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/lua/scratch_comments/ui/frame.lua b/lua/scratch_comments/ui/frame.lua index 49feb8c..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 = {} @@ -21,12 +22,9 @@ 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) ----@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 @@ -34,65 +32,94 @@ end ---@field comment_win integer ---@field comment_buf integer ---@field backdrop_win? integer +---@field update fun(spec: ScratchFrame, preserve_comment?: boolean) -- 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) - 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 } +function M.open(frame, parent) + ---@return { width: integer, col: integer, row: integer, context: integer, comment: integer, compact: boolean } 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 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 = frame.backdrop ~= false and backdrop.open(frame.lines) or nil + 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(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, @@ -101,11 +128,11 @@ 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, - { border = border, style = "minimal", title = " comment ", title_pos = "center" } + { style = "minimal", title = " comment ", title_pos = "center" } ) ) vim.wo[comment_win].wrap = true @@ -126,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 @@ -136,7 +222,10 @@ function M.open(frame) pcall(vim.api.nvim_del_augroup_by_id, group) return end - backdrop.resize(dim, frame.lines) + 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) @@ -164,13 +253,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 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 + 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, 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) + end + return panes end return M diff --git a/lua/scratch_comments/ui/preview.lua b/lua/scratch_comments/ui/preview.lua deleted file mode 100644 index 4481b1a..0000000 --- a/lua/scratch_comments/ui/preview.lua +++ /dev/null @@ -1,50 +0,0 @@ -local frame = require("scratch_comments.ui.frame") - -local M = {} - ----@type integer[] the panes of the open preview -local current = {} - ----@type ScratchFrame? what the open preview shows -local showing - -function M.close() - for _, win in ipairs(current) do - pcall(vim.api.nvim_win_close, win, true) - end - current = {} - showing = nil -end - --- A card beside whatever you are browsing: no focus, no editing. ----@param spec ScratchFrame -function M.show(spec) - 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 - - -- 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) - end) - end, - }) -end - -return M diff --git a/lua/scratch_comments/ui/quickfix.lua b/lua/scratch_comments/ui/quickfix.lua index c6070a9..b491a5d 100644 --- a/lua/scratch_comments/ui/quickfix.lua +++ b/lua/scratch_comments/ui/quickfix.lua @@ -9,54 +9,15 @@ 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[] ----@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.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 +26,27 @@ 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 }) + return entries +end + +---@param views ScratchCommentView[] +---@param orphans ScratchComment[] +function M.list(views, orphans) + 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 end return M diff --git a/lua/scratch_comments/ui/window.lua b/lua/scratch_comments/ui/window.lua index 263fefb..5e29b58 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 = {} @@ -16,26 +15,79 @@ 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 update? fun(spec: ScratchFrame) ---@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) - preview.close() spec.on_close = function() 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 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 @@ -51,6 +103,13 @@ 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 + 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 }) @@ -73,10 +132,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 +161,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 source or 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..8fc41b6 100644 --- a/plugin/scratch-comments.lua +++ b/plugin/scratch-comments.lua @@ -11,23 +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" }) - -command("CommentShow", function() - scratch().show() -end, { desc = "Show the comments at the cursor" }) +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 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" }) +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 c16760d..3e7966c 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.CommentDelete() expect.equality(#views.orphans(), 0) end) end) @@ -148,8 +146,105 @@ 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(), 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("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() + 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(helpers.sign_at(2), "│") + 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" }) + 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) + 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) + vim.cmd.CommentList() + vim.cmd.CommentDelete() expect.equality(#store.all(), 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) diff --git a/tests/features/commenting_spec.lua b/tests/features/commenting_spec.lua index 73cd311..50ea0f0 100644 --- a/tests/features/commenting_spec.lua +++ b/tests/features/commenting_spec.lua @@ -19,11 +19,12 @@ 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("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(views.anchored()[1].comment, "edited comment") @@ -72,6 +73,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 319ade6..8719733 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,518 @@ describe(":CommentList", function() end) 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.Comment() + 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.Comment() + vim.cmd.cclose() + vim.cmd.quit() + vim.wait(0) + expect.equality(vim.fn.getqflist({ winid = 0 }).winid, 0) + end) + + 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.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) + expect.equality(#helpers.every_float(), 0) + 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.Comment() + 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.Comment() + 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.Comment() + 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("does not guess a jump for an orphan; :Comment opens its card", 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(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 + 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.Comment() + 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.Comment() + 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.Comment() + 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") + helpers.text_changed() + vim.cmd.CommentList() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + vim.api.nvim_exec_autocmds("CursorMoved", { buffer = 0 }) + vim.cmd.Comment() + 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 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.Comment() + vim.cmd.CommentDelete() + expect.equality(#store.all(), 1) + expect.no_equality(store.all()[1].id, removed) + expect.equality(#vim.fn.getqflist(), 1) + 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 after opening its card", function() + vim.cmd("bwipeout! " .. source) + expect.equality(#store.all(), 2) + vim.cmd.CommentList() + vim.cmd.Comment() + vim.cmd.CommentDelete() + expect.equality(#store.all(), 1) + expect.equality(store.all()[1].comment, "on c") + 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) + 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.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" } } }) + vim.cmd.CommentDelete() + 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") + 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(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("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 + 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("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() + 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.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") + 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() + 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(#helpers.every_float(), 0) + expect.equality(store.all()[#store.all()].state, nil) + end) +end) + describe("markdown", function() it("names a range's lines", function() vim.cmd("1,2Comment") @@ -156,103 +669,187 @@ describe(":CommentList with a filter", function() end) describe("one card at a time", function() - it(":CommentList replaces an open comment window", function() - vim.cmd("CommentShow") + it(":CommentList closes an open comment window without opening another", function() + vim.cmd.CommentList() + vim.cmd.Comment() 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) it(":CommentList keeps an unsaved comment, and says so", function() - vim.cmd("CommentShow") + vim.cmd.CommentList() + 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("CommentShow") + vim.cmd.CommentList() + vim.cmd.Comment() vim.cmd("CommentExport!") expect.equality(#helpers.every_float(), 0) 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") 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) -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, and follows it", function() - vim.cmd("CommentList") - expect.equality(card(), "on a") - move_to(2) - expect.equality(card(), "on c") - 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("dims what is above it, leaving the list readable", function() - vim.cmd("CommentList") - local dim = vim.tbl_filter(function(win) + it("jumps on without opening a card", function() + vim.cmd.CommentList() + 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.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()) - 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) + 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)) + 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_win(), qf_win) + expect.equality(vim.api.nvim_win_get_cursor(source_win)[1], 1) 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 + 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 the focus in the list", function() - vim.cmd("CommentList") + it("keeps another editing split visible behind the card", function() + vim.cmd.vsplit() + vim.cmd.CommentList() + vim.cmd.Comment() + 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 \"]]) + 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() + vim.cmd.CommentList() + local qf_win = vim.api.nvim_get_current_win() + 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" }) + 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.every_float(), 0) 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/navigation_spec.lua b/tests/features/navigation_spec.lua deleted file mode 100644 index 830c2f4..0000000 --- a/tests/features/navigation_spec.lua +++ /dev/null @@ -1,134 +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) - vim.cmd("CommentDelete") - 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 662cd1d..1fae17e 100644 --- a/tests/features/plugin_spec.lua +++ b/tests/features/plugin_spec.lua @@ -21,7 +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("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 1f1a912..cb95019 100644 --- a/tests/features/show_delete_spec.lua +++ b/tests/features/show_delete_spec.lua @@ -10,88 +10,153 @@ 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 }) -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") - 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" }) - end) - - it("saves edits to the comment it shows", function() - helpers.pick(2) - vim.cmd("CommentShow") - helpers.write("picked the range") - expect.equality(#store.all(), 2) - expect.equality(views.anchored()[2].comment, "picked the range") - 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" - ) - end) + vim.cmd.CommentList() end) describe(":CommentDelete", function() - it("deletes the picked comment", function() - helpers.pick(1) - vim.cmd("CommentDelete") + 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(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() + vim.cmd.Comment() + vim.cmd.CommentDelete() + vim.wait(0) expect.equality(#store.all(), 1) expect.equality(views.anchored()[1].comment, "on one to three") end) - it("deletes nothing when the picker is cancelled", function() - helpers.pick(0) - vim.cmd("CommentDelete") - expect.equality(#store.all(), 2) + it("deletes the cursor row rather than a different open card", function() + local qf_win = vim.api.nvim_get_current_win() + 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 }) + 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("says so on a line with no comment and no orphans", function() - vim.api.nvim_win_set_cursor(0, { 4, 0 }) - vim.cmd("CommentDelete") + 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.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.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.Comment() + 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 not use the open card when another quickfix list replaces ours", function() + 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) + 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.Comment() + 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.Comment() + vim.cmd.quit() + vim.cmd.CommentDelete() + expect.equality(#store.all(), 2) + 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.Comment() + 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.Comment() + 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() + vim.cmd.Comment() + 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(#vim.fn.getqflist(), 1) + end) + + it("does not delete it when empty changes are discarded", function() + vim.cmd.Comment() + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "" }) + vim.cmd("q!") expect.equality(#store.all(), 2) - expect.equality(helpers.notified()[#helpers.notified()], "No comment at cursor") end) 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..b186a99 100644 --- a/tests/features/spans_spec.lua +++ b/tests/features/spans_spec.lua @@ -36,13 +36,23 @@ 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(helpers.text(), "on the line") - vim.cmd("quit") + vim.cmd.quit() expect.equality(#views.anchored(), 2) end) end) diff --git a/tests/features/window_spec.lua b/tests/features/window_spec.lua index e1b7f35..6ca3987 100644 --- a/tests/features/window_spec.lua +++ b/tests/features/window_spec.lua @@ -11,7 +11,9 @@ 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") + vim.cmd("1,3Comment") end) after_each(function() @@ -93,6 +95,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) @@ -117,7 +189,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") @@ -130,7 +208,8 @@ describe("the comment window", function() end vim.cmd("quit") local baseline = handlers() - vim.cmd("CommentShow") + vim.cmd.CommentList() + vim.cmd.Comment() expect.equality(handlers() > baseline, true) vim.cmd("quit") expect.equality(handlers(), baseline) @@ -156,13 +235,29 @@ describe("the border", function() it("is rounded when 'winborder' is not set", function() vim.o.winborder = "" - vim.cmd("CommentShow") + vim.cmd.CommentList() + 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("CommentShow") + vim.cmd.CommentList() + 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.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) + 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) 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()