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
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
100 changes: 100 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,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
Expand Down
122 changes: 110 additions & 12 deletions lua/reviewthem/diff/split.lua
Original file line number Diff line number Diff line change
Expand Up @@ -124,21 +124,54 @@ local function build_split_content(file)
return old_lines, new_lines, old_map, new_map
end

---@type table<number, number> 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<number, number> 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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading