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/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..419cf89 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,13 @@ 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. 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 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..97ef435 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,104 @@ 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.api.nvim_strwidth(line) <= max_width then + return { line } + end + + local wrapped = {} + local current = "" + local current_width = 0 + -- 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 = "" + current_width = 0 + end + current = current .. ch + current_width = current_width + w + end + if current ~= "" then + table.insert(wrapped, current) + end + 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 +---@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 +---@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 + 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(comment_display_lines(comment.text)) 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, + }) + 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. ---@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..f748406 100644 --- a/lua/reviewthem/diff/split.lua +++ b/lua/reviewthem/diff/split.lua @@ -124,21 +124,54 @@ 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) + 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() + 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 @@ -152,12 +185,78 @@ 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 + 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, { line_hl_group = "ReviewThemPadding", }) 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. @@ -278,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 @@ -291,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. @@ -362,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 new file mode 100644 index 0000000..bbf39cd --- /dev/null +++ b/tests/inline_comments_spec.lua @@ -0,0 +1,252 @@ +-- 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 + +--- 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", + 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, + }, + { + 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, + }, + }, +} + +-- 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") +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 new_blocks = comment_block_marks(new_bufnr) +local old_blocks = comment_block_marks(old_bufnr) + +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 = 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.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 max_text_width = 0 +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.api.nvim_strwidth(body)) + end +end +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)") +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") + +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 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") + +-- 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( + 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() + +if failures > 0 then + print(failures .. " check(s) failed") + os.exit(1) +end +print("all checks passed")