Retain session comments with a focused quickfix workflow #3

Merged
eleith merged 9 commits from feat/session-comments-reviewable into main 2026-09-30 17:27:19 +00:00
28 changed files with 2286 additions and 781 deletions

No files matched your search

+27 -38
View File
@@ -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 `<Esc>` closes it if you haven't changed anything, and `:q!`
throws your changes away. `<C-w>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", "<leader>ca", "<Cmd>Comment<CR>", { desc = "Comment on line" })
vim.keymap.set("x", "<leader>ca", ":Comment<CR>", { desc = "Comment on selection" })
vim.keymap.set("n", "<leader>cs", "<Cmd>CommentShow<CR>", { desc = "Show comments" })
vim.keymap.set("n", "<leader>cn", "<Cmd>CommentNext<CR>", { desc = "Next comment" })
vim.keymap.set("n", "<leader>cp", "<Cmd>CommentPrev<CR>", { desc = "Previous comment" })
vim.keymap.set("n", "<leader>cl", "<Cmd>CommentList<CR>", { desc = "List comments" })
vim.keymap.set("n", "<leader>cx", "<Cmd>CommentExport<CR>", { 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. `<CR>` 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. `<Esc>`
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
+51 -56
View File
@@ -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. <CR> 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. <Esc> closes a clean card from
the list or the card; unsaved changes require |:w| or
|:q!|. With no card open, <Esc> 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 <CR>/<Esc>
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", "<leader>ca", "<Cmd>Comment<CR>")
vim.keymap.set("x", "<leader>ca", ":Comment<CR>")
vim.keymap.set("n", "<leader>cs", "<Cmd>CommentShow<CR>")
vim.keymap.set("n", "<leader>cl", "<Cmd>CommentList<CR>")
vim.keymap.set("n", "<leader>cx", "<Cmd>CommentExport<CR>")
<
vim:tw=78:ts=8:noet:ft=help:norl:
+66 -103
View File
@@ -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
+192 -13
View File
@@ -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,97 @@ function M.clear()
store.clear()
end
-- Remember the latest live range for parking, or for a URI provider that
-- replaces an already-attached line without changing its contents.
---@param comment ScratchComment
---@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)
---@return boolean
local function snapshot(comment, bufnr)
local start_line, end_line, start_col, end_col = anchors.range(comment)
if not start_line then
return false
end
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")
return true
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.park_buffer(bufnr)
for _, comment in ipairs(store.in_buffer(bufnr)) do
if snapshot(comment, bufnr) then
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
local where = comment.last_location
local matches = false
if
(comment.source_name == name or comment.bufnr == bufnr)
and where.end_line <= vim.api.nvim_buf_line_count(bufnr)
then
local ok, text =
pcall(views.text, bufnr, where.start_line, where.end_line, where.start_col, where.end_col)
matches = ok and table.concat(text, "\n") == comment.snippet
end
-- A provider may replace an already-matching line without changing its
-- text. Reanchor that exact range; a changed or deleted line is an orphan.
if is_uri and comment.bufnr == bufnr and anchors.is_orphaned(comment) then
if matches then
anchors.clear_all({ comment })
anchors.anchor(comment, where.start_line, where.end_line, where.start_col, where.end_col)
end
changed = true
end
if comment.source_name == name and comment.state == "parked" then
if matches 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
end
end
if changed then
M.redraw(bufnr)
require("scratch_comments.list").source_updated(bufnr)
end
end
---@param on boolean
@@ -89,10 +194,84 @@ 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(_, _, _, first, last, new_last)
-- The next provider change can invalidate the mark before a
-- scheduled snapshot runs. Track line shifts above it immediately.
for _, comment in ipairs(store.in_buffer(bufnr)) do
local where = comment.last_location
if first <= where.start_line - 1 and last <= where.start_line - 1 then
local delta = new_last - last
where.start_line = where.start_line + delta
where.end_line = where.end_line + delta
end
end
vim.schedule(function()
if vim.api.nvim_buf_is_valid(bufnr) then
for _, comment in ipairs(store.in_buffer(bufnr)) do
snapshot(comment, bufnr)
end
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,
})
+1 -11
View File
@@ -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
+402 -21
View File
@@ -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,16 @@ local views = require("scratch_comments.model.views")
local M = {}
local list_id
local list_bufnr
local list_win
local list_tab
local active_filter
local pending_jump = {}
local listening = {}
local refresh
local bind_list
---@param comment ScratchComment|ScratchCommentView
---@return string
local function haystack(comment)
@@ -15,12 +26,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 +40,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 +48,399 @@ 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
---@param expected_id integer?
---@return integer?
local function manager_window(expected_id)
if not expected_id or vim.fn.getqflist({ id = 0 }).id ~= expected_id then
return nil
end
if
list_win
and vim.api.nvim_win_is_valid(list_win)
and vim.api.nvim_win_get_buf(list_win) == list_bufnr
then
return list_win
end
if not list_tab or not vim.api.nvim_tabpage_is_valid(list_tab) then
return nil
end
-- :cclose followed by :copen can give the same list a new window/buffer.
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(list_tab)) do
local bufnr = vim.api.nvim_win_get_buf(win)
if
vim.api.nvim_win_get_config(win).relative == ""
and vim.bo[bufnr].buftype == "quickfix"
and vim.fn.getwininfo(win)[1].loclist == 0
then
if bufnr ~= list_bufnr and bind_list then
bind_list(bufnr)
end
list_win, list_bufnr = win, bufnr
return win
end
end
end
---@return integer?, string?
local function selected()
if vim.bo.buftype ~= "quickfix" or vim.api.nvim_get_current_win() ~= manager_window(list_id) then
return nil
end
local qf = vim.fn.getqflist({ items = 1 })
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_win = manager_window(expected_id)
if not qf_win 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_win, function()
vim.cmd.cclose()
end)
list_id, list_bufnr, list_win, list_tab = nil, nil, 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_win, { 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_win = manager_window(list_id)
if not qf_win then
return
end
local row = vim.api.nvim_win_get_cursor(qf_win)[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
-- 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 tab = vim.api.nvim_get_current_tabpage()
local expected_id = list_id
local parent_win
actions.show(entry, qf_win, function()
vim.schedule(function()
local focused = vim.api.nvim_get_current_win()
if
vim.api.nvim_get_current_tabpage() == tab
and (focused == parent_win or focused == qf_win)
and manager_window(expected_id) == qf_win
then
vim.api.nvim_set_current_win(qf_win)
end
end)
end, function()
refresh(id, index, expected_id)
end)
local open = window.current()
if open and open.id == id then
parent_win = vim.api.nvim_win_get_config(open.comment_win).win
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
bind_list = function(bufnr)
vim.keymap.set("n", "<CR>", jump, { buffer = bufnr, desc = "Jump to comment source" })
vim.keymap.set("n", "<Esc>", escape, { buffer = bufnr, desc = "Dismiss comment card" })
end
vim.api.nvim_create_autocmd({ "WinEnter", "BufWinEnter" }, {
group = vim.api.nvim_create_augroup("scratch_comments_list", { clear = true }),
callback = function()
if vim.bo.buftype == "quickfix" and vim.api.nvim_get_current_tabpage() == list_tab then
manager_window(list_id)
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
local qf_win = manager_window(list_id)
if qf_win then
vim.fn.setqflist({}, "r", { items = {} })
vim.api.nvim_win_call(qf_win, function()
vim.cmd.cclose()
end)
end
end)
list_id, list_bufnr, list_win, list_tab = nil, nil, 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()
list_win = vim.api.nvim_get_current_win()
list_tab = vim.api.nvim_get_current_tabpage()
bind_list(list_bufnr)
return true
end
return M
+3
View File
@@ -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
+9 -2
View File
@@ -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,
+13 -4
View File
@@ -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
-92
View File
@@ -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
+8
View File
@@ -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
+16 -14
View File
@@ -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
+5 -2
View File
@@ -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),
}
+119 -30
View File
@@ -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
@@ -37,62 +35,90 @@ end
-- Two stacked floats, centered: the commented lines above, the comment below.
---@param frame ScratchFrame
---@param parent integer editing window to cover, not the quickfix list
---@return ScratchFramePanes
function M.open(frame)
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 +127,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 +152,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 +221,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 +252,14 @@ 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,
}
return panes
end
return M
-50
View File
@@ -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
+23 -50
View File
@@ -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
+71 -6
View File
@@ -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,78 @@ 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()
---@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
@@ -73,10 +124,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 +153,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
+2 -14
View File
@@ -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)
+206 -6
View File
@@ -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,210 @@ 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("makes a reattached URI comment an orphan when its line is deleted", function()
local url = "review-test:///edited-after-attach.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("on two")
vim.cmd("enew!")
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)
local source_win = vim.api.nvim_get_current_win()
vim.cmd.CommentList()
vim.api.nvim_win_call(source_win, function()
vim.api.nvim_buf_set_lines(0, 1, 2, false, {})
end)
vim.wait(0)
expect.equality(#views.orphans(), 1)
local items = vim.fn.getqflist()
expect.equality(items[#items].text, "[orphaned] on two")
end)
it("uses the latest live URI range after a provider replaces its lines", function()
local url = "review-test:///live-range.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("on two")
vim.cmd("enew!")
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)
local comment = store.all()[#store.all()]
expect.equality(comment.state, nil)
vim.api.nvim_buf_set_text(0, 1, 1, 1, 2, { "E" })
vim.wait(0)
expect.equality(comment.snippet, "tEo")
vim.api.nvim_buf_set_lines(0, 0, 0, false, { "leading" })
vim.wait(0)
expect.equality(comment.last_location.start_line, 3)
vim.api.nvim_buf_set_lines(0, 0, -1, false, { "leading", "one", "tEo" })
vim.wait(0)
expect.equality(comment.state, nil)
expect.equality(views.in_buffer(vim.api.nvim_get_current_buf())[1].start_line, 3)
end)
it("keeps a URI anchor through batched movement and provider replacement", function()
local url = "review-test:///batched.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("on two")
vim.cmd("enew!")
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)
local comment = store.all()[#store.all()]
vim.api.nvim_buf_set_lines(0, 0, 0, false, { "first" })
vim.wait(0)
vim.api.nvim_buf_set_lines(0, 0, 0, false, { "second" })
vim.api.nvim_buf_set_lines(0, 0, -1, false, { "second", "first", "one", "two" })
vim.wait(0)
expect.equality(comment.state, nil)
expect.equality(comment.last_location.start_line, 4)
expect.equality(views.in_buffer(vim.api.nvim_get_current_buf())[1].start_line, 4)
end)
it("parks a moved range at its current location and reattaches there", function()
local url = "review-test:///moved.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("on two")
vim.api.nvim_buf_set_lines(0, 0, 0, false, { "leading" })
local id = store.all()[#store.all()].id
expect.equality(
vim.iter(views.anchored()):find(function(view)
return view.id == id
end).start_line,
3
)
vim.cmd("enew!")
local comment = store.all()[#store.all()]
expect.equality(comment.state, "parked")
expect.equality(comment.last_location.start_line, 3)
expect.equality(comment.snippet, "two")
vim.api.nvim_buf_set_name(0, url)
vim.api.nvim_buf_set_lines(0, 0, -1, false, { "leading", "one", "two" })
vim.api.nvim_exec_autocmds("BufEnter", { buffer = 0 })
vim.wait(0)
expect.equality(comment.state, nil)
expect.equality(views.in_buffer(vim.api.nvim_get_current_buf())[1].start_line, 3)
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)
+22 -1
View File
@@ -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" })
+787 -62
View File
@@ -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,571 @@ 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()
expect.equality(vim.api.nvim_get_current_win(), file_win)
vim.wait(0)
expect.equality(vim.api.nvim_get_current_win(), file_win)
end)
it("does not pull focus back from another tab after closing a card", function()
vim.cmd.CommentList()
vim.cmd.Comment()
local card_win = vim.api.nvim_get_current_win()
vim.cmd.tabnew()
local other_tab = vim.api.nvim_get_current_tabpage()
vim.api.nvim_set_current_win(card_win)
vim.cmd("quit | tabnext")
vim.wait(0)
expect.equality(vim.api.nvim_get_current_tabpage(), other_tab)
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("removes edited comments that no longer match the active filter", function()
for _, comment in ipairs(store.all()) do
store.update(comment.id, { comment = "ZZQX9 " .. comment.comment })
end
vim.cmd("CommentList ZZQX9")
local removed = vim.fn.getqflist()[1].user_data.scratch_comments_id
vim.cmd.Comment()
vim.api.nvim_buf_set_lines(0, 0, -1, false, { "changed" })
vim.cmd.write()
local items = vim.fn.getqflist()
expect.equality(#items, 1)
expect.equality(items[1].text, "ZZQX9 on c")
expect.no_equality(items[1].user_data.scratch_comments_id, removed)
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("uses normal <CR> navigation when another quickfix list replaces ours", function()
local other = helpers.buffer("other-qf.lua", { "first", "second" })
vim.cmd.CommentList()
vim.fn.setqflist({}, " ", {
items = {
{ bufnr = other, lnum = 1, text = "first" },
{ bufnr = other, lnum = 2, text = "second" },
},
})
vim.api.nvim_win_set_cursor(0, { 2, 0 })
vim.cmd("normal \r")
expect.equality(vim.api.nvim_get_current_buf(), other)
expect.equality(vim.api.nvim_win_get_cursor(0)[1], 2)
expect.equality(#helpers.every_float(), 0)
end)
it("does not adopt a location list after the manager window closes", function()
vim.cmd.CommentList()
vim.cmd.cclose()
vim.fn.setloclist(0, { { bufnr = source, lnum = 1, text = "location" } }, " ")
vim.cmd.lopen()
vim.cmd.CommentDelete()
expect.equality(#store.all(), 2)
expect.equality(helpers.notified()[#helpers.notified()], "Not a Scratch Comments list")
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 +722,262 @@ 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 <CR> 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 manager after its quickfix window is reopened", function()
vim.cmd.CommentList()
local old_win = vim.api.nvim_get_current_win()
vim.cmd.cclose()
vim.cmd.copen()
local qf_win = vim.api.nvim_get_current_win()
expect.no_equality(qf_win, old_win)
expect.equality(vim.fn.maparg("<CR>", "n", false, true).desc, "Jump to comment source")
vim.cmd("wincmd k")
vim.cmd.Comment()
vim.api.nvim_buf_set_lines(0, 0, -1, false, { "edited after reopening" })
vim.cmd.write()
expect.equality(vim.fn.getqflist()[1].text, "edited after reopening")
expect.equality(vim.api.nvim_win_is_valid(qf_win), true)
end)
it("restores manager mappings on a recreated quickfix buffer", function()
vim.cmd.CommentList()
local qf_buf = vim.api.nvim_get_current_buf()
vim.cmd.cclose()
vim.cmd("bwipeout! " .. qf_buf)
vim.cmd.copen()
expect.no_equality(vim.api.nvim_get_current_buf(), qf_buf)
expect.equality(vim.fn.maparg("<CR>", "n", false, true).desc, "Jump to comment source")
end)
it("refreshes a list in another tab after editing 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.tabnew()
vim.cmd.buffer(source)
vim.cmd.Comment()
vim.api.nvim_buf_set_lines(0, 0, -1, false, { "edited in tab two" })
local editing_tab, editing_win =
vim.api.nvim_get_current_tabpage(), vim.api.nvim_get_current_win()
vim.cmd.write()
expect.equality(vim.fn.getqflist()[1].text, "edited in tab two")
expect.equality(vim.api.nvim_get_current_tabpage(), editing_tab)
expect.equality(vim.api.nvim_get_current_win(), editing_win)
expect.equality(vim.api.nvim_win_get_cursor(qf_win)[1], 2)
end)
it("refreshes a list in another tab after deleting from source", function()
vim.cmd.CommentList()
local qf_win = vim.api.nvim_get_current_win()
vim.cmd.tabnew()
vim.cmd.buffer(source)
vim.cmd.Comment()
local editing_tab, editing_win =
vim.api.nvim_get_current_tabpage(), vim.api.nvim_get_current_win()
vim.cmd.CommentDelete()
expect.equality(vim.api.nvim_get_current_tabpage(), editing_tab)
expect.equality(vim.api.nvim_get_current_win() ~= qf_win, true)
expect.equality(vim.api.nvim_win_is_valid(editing_win), false)
expect.equality(#vim.fn.getqflist(), 1)
expect.equality(vim.fn.getqflist()[1].text, "on c")
expect.equality(vim.api.nvim_win_is_valid(qf_win), true)
end)
it("closes an empty filtered manager in another tab without taking focus", function()
store.update(store.all()[2].id, { comment = "ZZQX9 only" })
vim.cmd("CommentList ZZQX9")
local qf_win = vim.api.nvim_get_current_win()
vim.cmd.tabnew()
vim.cmd.buffer(source)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
vim.cmd.Comment()
vim.api.nvim_buf_set_lines(0, 0, -1, false, { "no longer matches" })
local editing_tab = vim.api.nvim_get_current_tabpage()
vim.cmd.write()
expect.equality(vim.api.nvim_get_current_tabpage(), editing_tab)
expect.equality(vim.api.nvim_win_is_valid(qf_win), false)
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 <Esc> before opening a card", function()
vim.cmd.CommentList()
vim.cmd([[execute "normal \<Esc>"]])
expect.equality(vim.bo.buftype, "quickfix")
expect.equality(#helpers.every_float(), 0)
end)
it("closes a clean editor on <Esc> 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 \<Esc>"]])
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 \<Esc>"]])
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)
-134
View File
@@ -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 <C-o>", function()
vim.cmd("CommentNext")
vim.cmd([[execute "normal! \<C-o>"]])
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)
+8 -1
View File
@@ -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()
+130 -65
View File
@@ -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)
+11 -1
View File
@@ -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)
+100 -5
View File
@@ -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 <Esc> in normal mode", function()
vim.cmd([[execute "normal \<Esc>"]])
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)
+5
View File
@@ -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)
+9
View File
@@ -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()