From 36cc8e15d31e213128f3111804bbadd02ffec1af Mon Sep 17 00:00:00 2001 From: Kenta Yamaguchi Date: Sun, 5 Jul 2026 01:21:35 +0900 Subject: [PATCH 1/2] feat: display review comments inline in the diff view via virt_lines Render each comment's full text as a bordered virtual-line block below its anchor line (the comment's end_line on the matching side), in addition to the existing eol comment sign. Multiple comments on the same line are stacked in order, and long lines are wrapped by display width so multibyte (e.g. Japanese) text stays readable. - Add `inline_comments` config option (default: true) to toggle the feature - Add ReviewThemInlineComment / ReviewThemInlineCommentBorder highlight groups linked to Comment / NonText for theme friendliness - Add a headless functional test (tests/inline_comments_spec.lua) - Document the feature in README and :help reviewthem Co-Authored-By: Claude Fable 5 --- README.md | 2 + doc/reviewthem.txt | 7 ++ lua/reviewthem/config.lua | 1 + lua/reviewthem/diff/renderer.lua | 68 +++++++++++++++ lua/reviewthem/diff/split.lua | 26 +++++- tests/inline_comments_spec.lua | 144 +++++++++++++++++++++++++++++++ 6 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 tests/inline_comments_spec.lua diff --git a/README.md b/README.md index 86dacba..7f0669b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ This project is inspired by [ReviewIt](https://github.com/yoshiko-pg/reviewit) - - **Split diff view** — Side-by-side old/new comparison with syntax highlighting - **File tree sidebar** — Browse changed files, track review progress - **Line-level comments** — Floating input window, supports multi-line ranges +- **Inline comment display** — Comments are rendered as virtual text blocks right below the commented lines - **Session management** — Named sessions persisted as JSON, pause and resume anytime - **Markdown export** — Copy review output to clipboard, ready for coding agents - **Context-aware commands** — Only relevant commands are available at each stage @@ -140,6 +141,7 @@ All options are optional — defaults are shown below: ```lua require("reviewthem").setup({ comment_sign = "💬", -- sign shown on commented lines + inline_comments = true, -- show comment text inline below commented lines file_tree_width = 30, -- sidebar width in columns auto_save = true, -- auto-save session on changes keymaps = { diff --git a/doc/reviewthem.txt b/doc/reviewthem.txt index fdb34d3..d92f969 100644 --- a/doc/reviewthem.txt +++ b/doc/reviewthem.txt @@ -70,6 +70,7 @@ Call the setup function with optional configuration: >lua require("reviewthem").setup({ comment_sign = "💬", + inline_comments = true, file_tree_width = 30, auto_save = true, keymaps = { @@ -186,6 +187,12 @@ comment_sign~ Default: "💬" Sign displayed at the end of commented lines in the diff view. +inline_comments~ + Default: true + Render the full comment text inline below the commented line in the + diff view using virtual lines. Set to false to only show the + |comment_sign| indicator. + file_tree_width~ Default: 30 Width of the file tree sidebar in columns. diff --git a/lua/reviewthem/config.lua b/lua/reviewthem/config.lua index 395eec4..e6e9281 100644 --- a/lua/reviewthem/config.lua +++ b/lua/reviewthem/config.lua @@ -2,6 +2,7 @@ local M = {} M.defaults = { comment_sign = "💬", + inline_comments = true, file_tree_width = 30, auto_save = true, keymaps = { diff --git a/lua/reviewthem/diff/renderer.lua b/lua/reviewthem/diff/renderer.lua index c302093..72be54d 100644 --- a/lua/reviewthem/diff/renderer.lua +++ b/lua/reviewthem/diff/renderer.lua @@ -14,6 +14,8 @@ M.setup_highlights = function() ReviewThemLineNrNew = { default = true, fg = "#60e060" }, ReviewThemLineNrContext = { default = true, link = "LineNr" }, ReviewThemCommentSign = { default = true, fg = "#f0c060" }, + ReviewThemInlineComment = { default = true, link = "Comment" }, + ReviewThemInlineCommentBorder = { default = true, link = "NonText" }, ReviewThemSeparator = { default = true, fg = "#555555" }, ReviewThemPadding = { default = true, bg = "#1a1a2a" }, } @@ -83,6 +85,72 @@ M.add_comment_sign = function(bufnr, line_idx, sign) }) end +--- Wrap a single line of text by display width, safe for multibyte text. +---@param line string +---@param max_width number +---@return string[] +local function wrap_line(line, max_width) + if max_width < 1 then + max_width = 1 + end + if line == "" or vim.fn.strdisplaywidth(line) <= max_width then + return { line } + end + + local wrapped = {} + local current = "" + local current_width = 0 + -- Iterate over UTF-8 characters + for ch in line:gmatch("[\1-\127\194-\244][\128-\191]*") do + local w = vim.fn.strdisplaywidth(ch) + if current_width + w > max_width and current ~= "" then + table.insert(wrapped, current) + current = "" + current_width = 0 + end + current = current .. ch + current_width = current_width + w + end + if current ~= "" then + table.insert(wrapped, current) + end + return wrapped +end + +--- Render comment blocks inline below a buffer line using virt_lines. +--- Multiple comments are stacked in order. +---@param bufnr number +---@param line_idx number 0-indexed anchor line +---@param comments Comment[] +---@param sign string +---@param max_width number maximum display width for comment text lines +M.add_inline_comments = function(bufnr, line_idx, comments, sign, max_width) + local virt_lines = {} + for _, comment in ipairs(comments) do + local range = comment.start_line == comment.end_line and ("L" .. comment.start_line) + or ("L" .. comment.start_line .. "-" .. comment.end_line) + table.insert(virt_lines, { + { " ┌─ ", "ReviewThemInlineCommentBorder" }, + { sign .. " " .. range, "ReviewThemInlineComment" }, + { " ─", "ReviewThemInlineCommentBorder" }, + }) + for _, text_line in ipairs(vim.split(comment.text, "\n", { plain = true })) do + for _, chunk in ipairs(wrap_line(text_line, max_width)) do + table.insert(virt_lines, { + { " │ ", "ReviewThemInlineCommentBorder" }, + { chunk, "ReviewThemInlineComment" }, + }) + end + end + table.insert(virt_lines, { { " └─", "ReviewThemInlineCommentBorder" } }) + end + + vim.api.nvim_buf_set_extmark(bufnr, ns, line_idx, 0, { + virt_lines = virt_lines, + priority = 20, + }) +end + --- Add a file header decoration. ---@param bufnr number ---@param line_idx number 0-indexed diff --git a/lua/reviewthem/diff/split.lua b/lua/reviewthem/diff/split.lua index 49d8b35..8f9d949 100644 --- a/lua/reviewthem/diff/split.lua +++ b/lua/reviewthem/diff/split.lua @@ -131,14 +131,34 @@ end local function apply_split_decorations(bufnr, line_map, session) renderer.clear(bufnr) + local config = require("reviewthem.config").get() + local comment_lookup = {} + local inline_lookup = {} for _, c in ipairs(session.comments) do for l = c.start_line, c.end_line do comment_lookup[c.file .. ":" .. c.side .. ":" .. l] = true end + if config.inline_comments then + local key = c.file .. ":" .. c.side .. ":" .. c.end_line + inline_lookup[key] = inline_lookup[key] or {} + table.insert(inline_lookup[key], c) + end + end + for _, list in pairs(inline_lookup) do + table.sort(list, function(a, b) + if a.start_line ~= b.start_line then + return a.start_line < b.start_line + end + return tostring(a.id) < tostring(b.id) + end) end - local config = require("reviewthem.config").get() + -- Wrap width for inline comment text: keep blocks readable without + -- overflowing the window. + local winnr = vim.fn.bufwinid(bufnr) + local win_width = winnr ~= -1 and vim.api.nvim_win_get_width(winnr) or vim.o.columns + local wrap_width = math.max(20, math.min(80, win_width - 10)) for i, entry in ipairs(line_map) do local line_idx = i - 1 @@ -152,6 +172,10 @@ local function apply_split_decorations(bufnr, line_map, session) if comment_lookup[key] then renderer.add_comment_sign(bufnr, line_idx, config.comment_sign) end + local inline_comments = inline_lookup[key] + if inline_comments then + renderer.add_inline_comments(bufnr, line_idx, inline_comments, config.comment_sign, wrap_width) + end elseif entry.type == "padding" then vim.api.nvim_buf_set_extmark(bufnr, renderer.get_namespace(), line_idx, 0, { line_hl_group = "ReviewThemPadding", diff --git a/tests/inline_comments_spec.lua b/tests/inline_comments_spec.lua new file mode 100644 index 0000000..ac72ce9 --- /dev/null +++ b/tests/inline_comments_spec.lua @@ -0,0 +1,144 @@ +-- Headless functional check for inline comment rendering via virt_lines. +-- Run with: nvim --headless -l tests/inline_comments_spec.lua + +local script = debug.getinfo(1, "S").source:sub(2) +local root = vim.fn.fnamemodify(script, ":h:h") +vim.opt.rtp:prepend(root) + +require("reviewthem").setup() + +local renderer = require("reviewthem.diff.renderer") +local split = require("reviewthem.diff.split") + +local failures = 0 +local function check(cond, msg) + if cond then + print("ok - " .. msg) + else + failures = failures + 1 + print("FAIL - " .. msg) + end +end + +---@type DiffFile +local file = { + path = "test.lua", + status = "M", + hunks = { + { + header = "@@ -1,3 +1,3 @@", + old_start = 1, + old_count = 3, + new_start = 1, + new_count = 3, + lines = { + { type = "context", content = "line one", old_lineno = 1, new_lineno = 1 }, + { type = "remove", content = "old line", old_lineno = 2 }, + { type = "add", content = "new line", new_lineno = 2 }, + { type = "context", content = "line three", old_lineno = 3, new_lineno = 3 }, + }, + }, + }, +} + +local session = { + comments = { + { + id = "1", + file = "test.lua", + side = "new", + start_line = 2, + end_line = 2, + text = "Fix this\nplease", + created_at = 0, + updated_at = 0, + }, + { + id = "2", + file = "test.lua", + side = "new", + start_line = 1, + end_line = 2, + text = "これは日本語の長いコメントです。マルチバイト文字が表示幅で正しく折り返されることを確認します。" + .. "さらに長くしてラップを強制します。", + created_at = 0, + updated_at = 0, + }, + }, +} + +-- Two windows for old/new panes +vim.cmd("vsplit") +local wins = vim.api.nvim_tabpage_list_wins(0) +split.render_file(session, file, wins[1], wins[2]) + +local new_bufnr = vim.fn.bufnr("reviewthem://new") +check(new_bufnr ~= -1, "new diff buffer exists") + +local extmarks = vim.api.nvim_buf_get_extmarks(new_bufnr, renderer.get_namespace(), 0, -1, { details = true }) + +local virt_lines_marks = {} +for _, mark in ipairs(extmarks) do + if mark[4].virt_lines then + table.insert(virt_lines_marks, mark) + end +end + +check(#virt_lines_marks == 1, "exactly one virt_lines extmark on the new buffer (both comments share the anchor)") + +local mark = virt_lines_marks[1] +-- Buffer layout: row 0 file header, row 1 hunk header, row 2 context L1, row 3 add L2 +check(mark and mark[2] == 3, "virt_lines anchored at the buffer row of new line 2") + +local lines = {} +local max_text_width = 0 +for _, vline in ipairs(mark and mark[4].virt_lines or {}) do + local text = "" + for _, chunk in ipairs(vline) do + text = text .. chunk[1] + end + table.insert(lines, text) + local body = text:match("│ (.*)$") + if body then + max_text_width = math.max(max_text_width, vim.fn.strdisplaywidth(body)) + end +end +local joined = table.concat(lines, "\n") + +check(joined:find("💬 L1%-2") ~= nil, "range header rendered for multi-line comment (L1-2)") +check(joined:find("💬 L2") ~= nil, "header rendered for single-line comment (L2)") +check(joined:find("│ Fix this", 1, true) ~= nil, "first line of multi-line comment rendered") +check(joined:find("│ please", 1, true) ~= nil, "second line of multi-line comment rendered") +check(joined:find("日本語", 1, true) ~= nil, "multibyte comment text rendered") + +local header_count = select(2, joined:gsub("┌─", "")) +local footer_count = select(2, joined:gsub("└─", "")) +check(header_count == 2 and footer_count == 2, "two stacked comment blocks rendered") + +check(joined:find("💬 L1%-2") < joined:find("💬 L2%f[%D]"), "blocks sorted by start_line") +check(max_text_width <= 80, "wrapped text lines stay within max width (got " .. max_text_width .. ")") + +-- Japanese comment is wider than any window here, so it must have wrapped +local body_line_count = select(2, joined:gsub("│ ", "")) +check(body_line_count >= 4, "long multibyte comment wrapped onto multiple lines") + +-- Toggle off: no virt_lines should be produced +require("reviewthem.config").setup({ inline_comments = false }) +split.refresh_decorations(session) +local extmarks_off = vim.api.nvim_buf_get_extmarks(new_bufnr, renderer.get_namespace(), 0, -1, { details = true }) +local off_count = 0 +for _, m in ipairs(extmarks_off) do + if m[4].virt_lines then + off_count = off_count + 1 + end +end +check(off_count == 0, "no virt_lines when inline_comments = false") + +-- Avoid the accidental-close guard firing on exit in this headless harness +split.set_closing_intentionally() + +if failures > 0 then + print(failures .. " check(s) failed") + os.exit(1) +end +print("all checks passed") From 9b5e1178f3eb6f0f2e151a0058ddc7db3622c6ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 14:56:55 +0000 Subject: [PATCH 2/2] fix: keep split panes aligned and harden inline comment wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline comment blocks are virtual lines on one side of the diff only, so the other pane drifted upward on screen — 'scrollbind' syncs buffer lines, not screen rows. With a 5-line block and 22-line panes the old side showed buffer lines 1-22 while the new side showed 1-17. - Mirror each block's height as blank filler lines in the opposite pane, so both buffers occupy the same screen rows. Decoration now runs for both panes at once, since fillers need the other side's block heights. - Re-wrap comment text on WinResized/VimResized when the wrap width actually changes; the width used to be frozen at first render. - Keep bytes that are not valid UTF-8 while wrapping. The old character pattern could not start a character on 0x00 or 0xF5-0xFF and dropped them. - Expand tabs before measuring width. nvim_strwidth reports 1 for a tab while it draws as 'tabstop' columns, which split a line at every tab. - Measure with nvim_strwidth instead of a strdisplaywidth call per character. - Drop the |comment_sign| doc link: no such help tag exists. - Tests: assert against the wrap width the renderer was given instead of a constant that passed trivially, verify both panes end up with equal virtual line height per row, cover tabs and invalid UTF-8, and make the ordering check fail instead of erroring when a pattern does not match. - Add a make test target so the checks are runnable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QvF1sbz4NudDsjqzQDivGS --- Makefile | 5 + doc/reviewthem.txt | 5 +- lua/reviewthem/diff/renderer.lua | 42 +++++++- lua/reviewthem/diff/split.lua | 108 ++++++++++++++++--- tests/inline_comments_spec.lua | 172 +++++++++++++++++++++++++------ 5 files changed, 276 insertions(+), 56 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3e34eff --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +.PHONY: test + +# Headless functional checks. Requires Neovim >= 0.10.0 on PATH. +test: + nvim --headless -l tests/inline_comments_spec.lua diff --git a/doc/reviewthem.txt b/doc/reviewthem.txt index d92f969..419cf89 100644 --- a/doc/reviewthem.txt +++ b/doc/reviewthem.txt @@ -190,8 +190,9 @@ comment_sign~ inline_comments~ Default: true Render the full comment text inline below the commented line in the - diff view using virtual lines. Set to false to only show the - |comment_sign| indicator. + diff view using virtual lines. Blank filler lines are added to the + opposite pane so both sides stay aligned. Set to false to only show + the comment_sign indicator. file_tree_width~ Default: 30 diff --git a/lua/reviewthem/diff/renderer.lua b/lua/reviewthem/diff/renderer.lua index 72be54d..97ef435 100644 --- a/lua/reviewthem/diff/renderer.lua +++ b/lua/reviewthem/diff/renderer.lua @@ -93,16 +93,17 @@ local function wrap_line(line, max_width) if max_width < 1 then max_width = 1 end - if line == "" or vim.fn.strdisplaywidth(line) <= max_width then + if line == "" or vim.api.nvim_strwidth(line) <= max_width then return { line } end local wrapped = {} local current = "" local current_width = 0 - -- Iterate over UTF-8 characters - for ch in line:gmatch("[\1-\127\194-\244][\128-\191]*") do - local w = vim.fn.strdisplaywidth(ch) + -- Iterate over characters: any non-continuation byte starts a new one, so + -- bytes that are not valid UTF-8 are kept instead of being dropped. + for ch in line:gmatch("[^\128-\191][\128-\191]*") do + local w = vim.api.nvim_strwidth(ch) if current_width + w > max_width and current ~= "" then table.insert(wrapped, current) current = "" @@ -117,6 +118,15 @@ local function wrap_line(line, max_width) return wrapped end +--- Split comment text into display lines: tabs are expanded and CR is treated +--- as a line break, so widths measured by wrap_line match what is drawn. +---@param text string +---@return string[] +local function comment_display_lines(text) + local normalized = text:gsub("\r\n", "\n"):gsub("\r", "\n"):gsub("\t", " ") + return vim.split(normalized, "\n", { plain = true }) +end + --- Render comment blocks inline below a buffer line using virt_lines. --- Multiple comments are stacked in order. ---@param bufnr number @@ -124,6 +134,7 @@ end ---@param comments Comment[] ---@param sign string ---@param max_width number maximum display width for comment text lines +---@return number number of virtual lines added M.add_inline_comments = function(bufnr, line_idx, comments, sign, max_width) local virt_lines = {} for _, comment in ipairs(comments) do @@ -134,7 +145,7 @@ M.add_inline_comments = function(bufnr, line_idx, comments, sign, max_width) { sign .. " " .. range, "ReviewThemInlineComment" }, { " ─", "ReviewThemInlineCommentBorder" }, }) - for _, text_line in ipairs(vim.split(comment.text, "\n", { plain = true })) do + for _, text_line in ipairs(comment_display_lines(comment.text)) do for _, chunk in ipairs(wrap_line(text_line, max_width)) do table.insert(virt_lines, { { " │ ", "ReviewThemInlineCommentBorder" }, @@ -149,6 +160,27 @@ M.add_inline_comments = function(bufnr, line_idx, comments, sign, max_width) virt_lines = virt_lines, priority = 20, }) + return #virt_lines +end + +--- Add blank virtual lines below a buffer line. +--- Used to mirror the height of an inline comment block in the opposite pane so +--- the two split buffers keep the same screen rows. +---@param bufnr number +---@param line_idx number 0-indexed anchor line +---@param count number number of blank lines (no-op when <= 0) +M.add_filler_lines = function(bufnr, line_idx, count) + if count <= 0 then + return + end + local virt_lines = {} + for _ = 1, count do + table.insert(virt_lines, { { "", "ReviewThemInlineCommentBorder" } }) + end + vim.api.nvim_buf_set_extmark(bufnr, ns, line_idx, 0, { + virt_lines = virt_lines, + priority = 20, + }) end --- Add a file header decoration. diff --git a/lua/reviewthem/diff/split.lua b/lua/reviewthem/diff/split.lua index 8f9d949..f748406 100644 --- a/lua/reviewthem/diff/split.lua +++ b/lua/reviewthem/diff/split.lua @@ -124,10 +124,24 @@ local function build_split_content(file) return old_lines, new_lines, old_map, new_map end +---@type table Last wrap width used per buffer, to skip no-op refreshes +local wrap_widths = {} + +--- Wrap width for inline comment text: keep blocks readable without +--- overflowing the window. +---@param bufnr number +---@return number +local function compute_wrap_width(bufnr) + local winid = vim.fn.bufwinid(bufnr) + local win_width = winid ~= -1 and vim.api.nvim_win_get_width(winid) or vim.o.columns + return math.max(20, math.min(80, win_width - 10)) +end + --- Apply decorations to a split buffer. ---@param bufnr number ---@param line_map table[] ---@param session ReviewSession +---@return table inline_heights virt_lines count per 0-indexed row local function apply_split_decorations(bufnr, line_map, session) renderer.clear(bufnr) @@ -154,11 +168,10 @@ local function apply_split_decorations(bufnr, line_map, session) end) end - -- Wrap width for inline comment text: keep blocks readable without - -- overflowing the window. - local winnr = vim.fn.bufwinid(bufnr) - local win_width = winnr ~= -1 and vim.api.nvim_win_get_width(winnr) or vim.o.columns - local wrap_width = math.max(20, math.min(80, win_width - 10)) + local wrap_width = compute_wrap_width(bufnr) + wrap_widths[bufnr] = wrap_width + + local inline_heights = {} for i, entry in ipairs(line_map) do local line_idx = i - 1 @@ -174,7 +187,8 @@ local function apply_split_decorations(bufnr, line_map, session) end local inline_comments = inline_lookup[key] if inline_comments then - renderer.add_inline_comments(bufnr, line_idx, inline_comments, config.comment_sign, wrap_width) + inline_heights[line_idx] = + renderer.add_inline_comments(bufnr, line_idx, inline_comments, config.comment_sign, wrap_width) end elseif entry.type == "padding" then vim.api.nvim_buf_set_extmark(bufnr, renderer.get_namespace(), line_idx, 0, { @@ -182,6 +196,67 @@ local function apply_split_decorations(bufnr, line_map, session) }) end end + + return inline_heights +end + +--- Decorate both panes of the current view. +--- Inline comment blocks only exist on the side they belong to, so the opposite +--- pane gets blank filler lines of the same height. Without them the panes would +--- drift apart on screen: 'scrollbind' syncs buffer lines, not screen rows. +---@param session ReviewSession +local function apply_both_decorations(session) + local old_bufnr = view_state.old_bufnr + local new_bufnr = view_state.new_bufnr + local old_valid = old_bufnr ~= nil and vim.api.nvim_buf_is_valid(old_bufnr) + local new_valid = new_bufnr ~= nil and vim.api.nvim_buf_is_valid(new_bufnr) + + local old_heights = old_valid and apply_split_decorations(old_bufnr, view_state.line_map_old, session) or {} + local new_heights = new_valid and apply_split_decorations(new_bufnr, view_state.line_map_new, session) or {} + + if not (old_valid and new_valid) then + return + end + + -- Both line maps are built in lockstep, so a row index means the same + -- position in either pane. + for line_idx, height in pairs(old_heights) do + renderer.add_filler_lines(new_bufnr, line_idx, height - (new_heights[line_idx] or 0)) + end + for line_idx, height in pairs(new_heights) do + renderer.add_filler_lines(old_bufnr, line_idx, height - (old_heights[line_idx] or 0)) + end +end + +local RESIZE_AUGROUP = "ReviewThemSplitResize" + +--- Re-render decorations when a pane resize changes the inline comment wrap +--- width. Registered once per render; the augroup is cleared on re-register. +local function setup_resize_refresh() + local group = vim.api.nvim_create_augroup(RESIZE_AUGROUP, { clear = true }) + vim.api.nvim_create_autocmd({ "WinResized", "VimResized" }, { + group = group, + callback = function() + local session = view_state.session + if not session then + return + end + local changed = false + for _, bufnr in ipairs({ view_state.old_bufnr, view_state.new_bufnr }) do + if bufnr and vim.api.nvim_buf_is_valid(bufnr) and wrap_widths[bufnr] ~= compute_wrap_width(bufnr) then + changed = true + end + end + if not changed then + return + end + vim.schedule(function() + if view_state.session then + M.refresh_decorations(view_state.session) + end + end) + end, + }) end --- Prevent accidental close of diff buffer windows. @@ -302,11 +377,7 @@ M.render_file = function(session, file, old_winnr, new_winnr) }) end - -- Apply decorations - apply_split_decorations(old_bufnr, old_map, session) - apply_split_decorations(new_bufnr, new_map, session) - - -- Update state + -- Update state (decorations below need both line maps for pane alignment) view_state.old_bufnr = old_bufnr view_state.new_bufnr = new_bufnr view_state.old_winnr = old_winnr @@ -315,17 +386,18 @@ M.render_file = function(session, file, old_winnr, new_winnr) view_state.line_map_new = new_map view_state.current_file = file.path view_state.session = session + + -- Apply decorations + apply_both_decorations(session) + + -- Re-wrap inline comments when the panes change width + setup_resize_refresh() end --- Refresh decorations for the current split view. ---@param session ReviewSession M.refresh_decorations = function(session) - if view_state.old_bufnr and vim.api.nvim_buf_is_valid(view_state.old_bufnr) then - apply_split_decorations(view_state.old_bufnr, view_state.line_map_old, session) - end - if view_state.new_bufnr and vim.api.nvim_buf_is_valid(view_state.new_bufnr) then - apply_split_decorations(view_state.new_bufnr, view_state.line_map_new, session) - end + apply_both_decorations(session) end --- Get context info for cursor position in either split buffer. @@ -386,11 +458,13 @@ end --- Close split view buffers. M.close = function() closing_intentionally = true + pcall(vim.api.nvim_del_augroup_by_name, RESIZE_AUGROUP) for _, bufnr in ipairs({ view_state.old_bufnr, view_state.new_bufnr }) do if bufnr and vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_delete(bufnr, { force = true }) end end + wrap_widths = {} view_state.old_bufnr = nil view_state.new_bufnr = nil view_state.old_winnr = nil diff --git a/tests/inline_comments_spec.lua b/tests/inline_comments_spec.lua index ac72ce9..bbf39cd 100644 --- a/tests/inline_comments_spec.lua +++ b/tests/inline_comments_spec.lua @@ -20,6 +20,55 @@ local function check(cond, msg) end end +--- All virt_lines extmarks of a buffer as { row = 0-indexed, lines = string[] }. +---@param bufnr number +---@return table[] +local function virt_line_marks(bufnr) + local marks = {} + local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, renderer.get_namespace(), 0, -1, { details = true }) + for _, mark in ipairs(extmarks) do + if mark[4].virt_lines then + local lines = {} + for _, vline in ipairs(mark[4].virt_lines) do + local text = "" + for _, chunk in ipairs(vline) do + text = text .. chunk[1] + end + table.insert(lines, text) + end + table.insert(marks, { row = mark[2], lines = lines }) + end + end + return marks +end + +--- Marks that actually draw text, i.e. comment blocks and not alignment fillers. +---@param bufnr number +---@return table[] +local function comment_block_marks(bufnr) + local blocks = {} + for _, mark in ipairs(virt_line_marks(bufnr)) do + for _, line in ipairs(mark.lines) do + if line ~= "" then + table.insert(blocks, mark) + break + end + end + end + return blocks +end + +--- Total number of virtual lines per buffer row. +---@param bufnr number +---@return table +local function virt_line_heights(bufnr) + local heights = {} + for _, mark in ipairs(virt_line_marks(bufnr)) do + heights[mark.row] = (heights[mark.row] or 0) + #mark.lines + end + return heights +end + ---@type DiffFile local file = { path = "test.lua", @@ -64,6 +113,16 @@ local session = { created_at = 0, updated_at = 0, }, + { + id = "3", + file = "test.lua", + side = "old", + start_line = 2, + end_line = 2, + text = "comment on the base side", + created_at = 0, + updated_at = 0, + }, }, } @@ -73,37 +132,33 @@ local wins = vim.api.nvim_tabpage_list_wins(0) split.render_file(session, file, wins[1], wins[2]) local new_bufnr = vim.fn.bufnr("reviewthem://new") +local old_bufnr = vim.fn.bufnr("reviewthem://old") check(new_bufnr ~= -1, "new diff buffer exists") +check(old_bufnr ~= -1, "old diff buffer exists") -local extmarks = vim.api.nvim_buf_get_extmarks(new_bufnr, renderer.get_namespace(), 0, -1, { details = true }) - -local virt_lines_marks = {} -for _, mark in ipairs(extmarks) do - if mark[4].virt_lines then - table.insert(virt_lines_marks, mark) - end -end +local new_blocks = comment_block_marks(new_bufnr) +local old_blocks = comment_block_marks(old_bufnr) -check(#virt_lines_marks == 1, "exactly one virt_lines extmark on the new buffer (both comments share the anchor)") +check(#new_blocks == 1, "one comment block on the new pane (both new-side comments share the anchor)") +check(#old_blocks == 1, "one comment block on the old pane (the base-side comment)") -local mark = virt_lines_marks[1] +local mark = new_blocks[1] -- Buffer layout: row 0 file header, row 1 hunk header, row 2 context L1, row 3 add L2 -check(mark and mark[2] == 3, "virt_lines anchored at the buffer row of new line 2") +check(mark and mark.row == 3, "comment block anchored at the buffer row of new line 2") + +-- Recompute the wrap width the renderer was given, so this tracks the window +-- instead of a hardcoded number that passes trivially. +local win_width = vim.api.nvim_win_get_width(wins[2]) +local wrap_width = math.max(20, math.min(80, win_width - 10)) -local lines = {} local max_text_width = 0 -for _, vline in ipairs(mark and mark[4].virt_lines or {}) do - local text = "" - for _, chunk in ipairs(vline) do - text = text .. chunk[1] - end - table.insert(lines, text) +for _, text in ipairs(mark and mark.lines or {}) do local body = text:match("│ (.*)$") if body then - max_text_width = math.max(max_text_width, vim.fn.strdisplaywidth(body)) + max_text_width = math.max(max_text_width, vim.api.nvim_strwidth(body)) end end -local joined = table.concat(lines, "\n") +local joined = table.concat(mark and mark.lines or {}, "\n") check(joined:find("💬 L1%-2") ~= nil, "range header rendered for multi-line comment (L1-2)") check(joined:find("💬 L2") ~= nil, "header rendered for single-line comment (L2)") @@ -115,24 +170,77 @@ local header_count = select(2, joined:gsub("┌─", "")) local footer_count = select(2, joined:gsub("└─", "")) check(header_count == 2 and footer_count == 2, "two stacked comment blocks rendered") -check(joined:find("💬 L1%-2") < joined:find("💬 L2%f[%D]"), "blocks sorted by start_line") -check(max_text_width <= 80, "wrapped text lines stay within max width (got " .. max_text_width .. ")") +local first_pos = joined:find("💬 L1%-2") +local second_pos = joined:find("💬 L2%f[%D]") +check(first_pos ~= nil and second_pos ~= nil and first_pos < second_pos, "blocks sorted by start_line") +check( + max_text_width <= wrap_width, + string.format("wrapped text stays within the wrap width (got %d, limit %d)", max_text_width, wrap_width) +) --- Japanese comment is wider than any window here, so it must have wrapped +-- Japanese comment is wider than the wrap width, so it must have wrapped local body_line_count = select(2, joined:gsub("│ ", "")) check(body_line_count >= 4, "long multibyte comment wrapped onto multiple lines") --- Toggle off: no virt_lines should be produced -require("reviewthem.config").setup({ inline_comments = false }) -split.refresh_decorations(session) -local extmarks_off = vim.api.nvim_buf_get_extmarks(new_bufnr, renderer.get_namespace(), 0, -1, { details = true }) -local off_count = 0 -for _, m in ipairs(extmarks_off) do - if m[4].virt_lines then - off_count = off_count + 1 +-- The panes are scrollbind/cursorbind'ed, so each row must occupy the same +-- number of screen rows on both sides or the split view drifts apart. +local old_heights = virt_line_heights(old_bufnr) +local new_heights = virt_line_heights(new_bufnr) +local misaligned_row = nil +for row in pairs(vim.tbl_extend("force", old_heights, new_heights)) do + if (old_heights[row] or 0) ~= (new_heights[row] or 0) then + misaligned_row = row end end -check(off_count == 0, "no virt_lines when inline_comments = false") +check( + misaligned_row == nil, + "both panes have equal virtual line height on every row" + .. (misaligned_row and (" (row " .. misaligned_row .. " differs)") or "") +) +check(next(old_heights) ~= nil and next(new_heights) ~= nil, "alignment fillers added on both panes") + +-- Tabs and non-UTF-8 bytes: the measured width must match the drawn text and no +-- byte may be dropped while wrapping. +local scratch = vim.api.nvim_create_buf(false, true) +vim.api.nvim_buf_set_lines(scratch, 0, -1, false, { "anchor" }) + +--- Render one comment on a scratch buffer and return its text lines. +---@param text string +---@param max_width number +---@return string[] +local function render_body(text, max_width) + vim.api.nvim_buf_clear_namespace(scratch, renderer.get_namespace(), 0, -1) + renderer.add_inline_comments(scratch, 0, { + { id = "x", file = "f", side = "new", start_line = 1, end_line = 1, text = text }, + }, "*", max_width) + local body = {} + for _, m in ipairs(virt_line_marks(scratch)) do + for _, line in ipairs(m.lines) do + local line_body = line:match("│ (.*)$") + if line_body then + table.insert(body, line_body) + end + end + end + return body +end + +local tab_body = render_body("a\tb", 40) +check(#tab_body == 1 and tab_body[1] == "a b", "tab expanded to spaces instead of forcing a line break") + +local raw_text = "0123456789\255" .. "0123456789" +local raw_body = render_body(raw_text, 8) +-- Neovim draws a byte that is not valid UTF-8 as in virtual text; what +-- matters here is that wrap_line does not silently swallow it. +local raw_expected = (raw_text:gsub("\255", "")) +check(#raw_body > 1, "a line longer than the wrap width is split") +check(table.concat(raw_body, "") == raw_expected, "wrapping keeps bytes that are not valid UTF-8") + +-- Toggle off: no virt_lines should be produced on either pane +require("reviewthem.config").setup({ inline_comments = false }) +split.refresh_decorations(session) +check(#virt_line_marks(new_bufnr) == 0, "no virt_lines on the new pane when inline_comments = false") +check(#virt_line_marks(old_bufnr) == 0, "no virt_lines on the old pane when inline_comments = false") -- Avoid the accidental-close guard firing on exit in this headless harness split.set_closing_intentionally()