Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
8 changes: 8 additions & 0 deletions doc/reviewthem.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 12 additions & 6 deletions lua/reviewthem/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -248,18 +248,24 @@ register_session_commands = function()
local state = require("reviewthem.session.state")
local session = state.get_active()
local ui_mod = require("reviewthem.ui")
local context = ui_mod.get_cursor_context()

-- Resolve selected buffer rows to file linenos via the line map: buffer
-- rows include padding and header lines, so adding the row delta to the
-- start lineno would overshoot the hunk.
local context
if cmd.range == 2 then
context = ui_mod.get_range_context(cmd.line1, cmd.line2)
else
context = ui_mod.get_cursor_context()
end

if not context then
vim.notify("Place cursor on a diff line to add a comment.", vim.log.levels.WARN)
return
end

local start_line = context.lineno
local end_line = context.lineno
if cmd.range == 2 then
end_line = start_line + (cmd.line2 - cmd.line1)
end
local start_line = context.start_lineno
local end_line = context.end_lineno

local prefix = context.hunk_line.type == "add" and "+" or
context.hunk_line.type == "remove" and "-" or " "
Expand Down
1 change: 1 addition & 0 deletions lua/reviewthem/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ local M = {}

M.defaults = {
comment_sign = "💬",
inline_comments = true,
file_tree_width = 30,
auto_save = true,
keymaps = {
Expand Down
8 changes: 8 additions & 0 deletions lua/reviewthem/diff/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ M.get_cursor_context = function()
return split.get_cursor_context()
end

--- Get context for a range of buffer rows.
---@param row1 number
---@param row2 number
---@return table|nil
M.get_range_context = function(row1, row2)
return split.get_range_context(row1, row2)
end

--- Show a specific file.
---@param session ReviewSession
---@param file DiffFile
Expand Down
116 changes: 116 additions & 0 deletions lua/reviewthem/diff/renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
}
Expand Down Expand Up @@ -83,6 +85,120 @@ 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
local function take(ch)
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
-- 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. The
-- pattern cannot match continuation bytes at the start of the string, so
-- take that run separately first.
local head = line:match("^[\128-\191]+")
if head then
take(head)
end
for ch in line:sub(head and #head + 1 or 1):gmatch("[^\128-\191][\128-\191]*") do
take(ch)
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", " ")
-- Remaining control chars are drawn caret-notated by virt_lines (^X, two
-- cells) but measured as one cell by nvim_strwidth; make the caret form
-- literal so measured and drawn widths agree.
normalized = normalized:gsub("[%z\1-\9\11-\31\127]", function(c)
local b = c:byte()
return b == 127 and "^?" or ("^" .. string.char(b + 64))
end)
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
Expand Down
Loading