Skip to content
Merged
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Add comments to any line or code block, export as structured Markdown, and paste
- 👁️ **Smart navigation** - Jump between comments, view at cursor
- ✏️ **Live preview** - See and edit all your comments in one place
- 🔍 **Find comments** - List all with `<leader>rl` using Telescope, fzf, or quickfix
- 💬 **Thread discussions** - Reply to comments and resolve threads

## 📦 Installation

Expand Down Expand Up @@ -268,6 +269,8 @@ end
| `:CodeReviewCopy` | `<leader>ry` | Copy review to clipboard |
| `:CodeReviewClear` | `<leader>rx` | Clear all review comments |
| `:CodeReviewDeleteComment` | `<leader>rd` | Delete comment at cursor position |
| `:CodeReviewResolveThread` | - | Mark current thread as resolved |
| `:CodeReviewReopenThread` | - | Reopen a resolved thread |

### Visual Indicators

Expand Down Expand Up @@ -305,6 +308,15 @@ The preview buffer is fully editable. You can:
- Save with `:w` to update the review
- Close with `q`

### Thread Discussions

Comments automatically create discussion threads. You can:

- **Reply to comments**: Add comments on the same line to create a thread
- **Resolve threads**: Use `:CodeReviewResolveThread` when discussion is complete
- **Reopen threads**: Use `:CodeReviewReopenThread` if more discussion is needed
- Thread status is displayed in the preview (`[open]` or `[resolved]`)

### Comment List Picker

<img src="assets/screenshot/picker.png" alt="Comment List Picker" />
Expand Down
48 changes: 35 additions & 13 deletions lua/code-review/comment.lua
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function M.add(context_lines)
return
end

-- Create comment data
-- Always create a new comment (new thread)
local comment_data = {
file = context.file,
line_start = context.line_start,
Expand All @@ -48,7 +48,7 @@ function M.add(context_lines)

-- Copy to clipboard if enabled
local config = require("code-review.config")
if config.get("comment.auto_copy_on_add") then
if config.get("comment.auto_copy_on_add") and comment_data then
-- Format the comment with full context (like <leader>rs shows)
local formatted_lines = M.format_as_markdown(comment_data, true, false)
local formatted_text = table.concat(formatted_lines, "\n")
Expand Down Expand Up @@ -149,26 +149,48 @@ end
add_virtual_text = function(bufnr, comments)
local config = require("code-review.config").get("ui.virtual_text")

-- Group comments by line for virtual text
local comments_by_line = {}
-- Group comments by line and thread for virtual text
local threads_by_line = {}
for _, comment in ipairs(comments) do
-- Only show on first line of range
local line = comment.line_start
if not comments_by_line[line] then
comments_by_line[line] = {}
if not threads_by_line[line] then
threads_by_line[line] = {}
end
table.insert(comments_by_line[line], comment)

-- Group by thread
local thread_id = comment.thread_id or comment.id
if not threads_by_line[line][thread_id] then
threads_by_line[line][thread_id] = {}
end
table.insert(threads_by_line[line][thread_id], comment)
end

-- Add virtual text
for line, line_comments in pairs(comments_by_line) do
for line, line_threads in pairs(threads_by_line) do
local text = config.prefix
if #line_comments > 1 then
-- Multiple comments on same line
text = text .. string.format("(%d comments)", #line_comments)
local thread_count = vim.tbl_count(line_threads)

if thread_count > 1 then
-- Multiple threads on same line
text = text .. string.format("(%d threads)", thread_count)
else
-- Single comment - show first line of comment
local first_line = line_comments[1].comment:match("^[^\n]*") or line_comments[1].comment
-- Single thread - find the latest comment
local _, thread_comments = next(line_threads)

-- Find the latest comment (last in thread)
local latest_comment = thread_comments[#thread_comments]

-- If no timestamp, assume comments are in chronological order
if thread_comments[1].timestamp then
-- Sort by timestamp to find the latest
table.sort(thread_comments, function(a, b)
return (a.timestamp or 0) < (b.timestamp or 0)
end)
latest_comment = thread_comments[#thread_comments]
end
local first_line = latest_comment.comment:match("^[^\n]*") or latest_comment.comment

-- Truncate if too long
if #first_line > 40 then
first_line = first_line:sub(1, 37) .. "..."
Expand Down
10 changes: 10 additions & 0 deletions lua/code-review/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ local defaults = {
mode = "n",
key = "<leader>rd",
},
-- Reply to comment at cursor
reply_comment = {
mode = "n",
key = "<leader>rr",
},
-- Resolve thread at cursor
resolve_thread = {
mode = "n",
key = "<leader>ro",
},
},
-- Integration settings
integrations = {
Expand Down
169 changes: 169 additions & 0 deletions lua/code-review/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,28 @@ function M.setup(opts)
M.delete_comment_at_cursor()
end, { desc = "Delete comment at cursor position" })

vim.api.nvim_create_user_command("CodeReviewReply", function()
M.reply_to_comment_at_cursor()
end, { desc = "Reply to comment at cursor position" })

vim.api.nvim_create_user_command("CodeReviewResolve", function()
M.resolve_thread_at_cursor()
end, { desc = "Resolve thread at cursor position" })

vim.api.nvim_create_user_command("CodeReviewSetStatus", function(args)
if args.args == "" then
vim.notify("Usage: :CodeReviewSetStatus <draft|open|resolved|closed>", vim.log.levels.ERROR)
return
end
M.set_review_status(args.args)
end, {
desc = "Set review status",
nargs = 1,
complete = function()
return { "draft", "open", "resolved", "closed" }
end,
})

-- Setup keymaps if enabled
local keymaps = config.get("keymaps")
if keymaps then
Expand All @@ -79,6 +101,8 @@ function M.setup(opts)
show_comment = "Show comment at cursor",
list_comments = "List all comments",
delete_comment = "Delete comment at cursor",
reply_comment = "Reply to comment at cursor",
resolve_thread = "Resolve thread at cursor",
}

local func = {
Expand All @@ -92,6 +116,8 @@ function M.setup(opts)
show_comment = M.show_comment_at_cursor,
list_comments = M.list_comments,
delete_comment = M.delete_comment_at_cursor,
reply_comment = M.reply_to_comment_at_cursor,
resolve_thread = M.resolve_thread_at_cursor,
}

if func[action] then
Expand Down Expand Up @@ -208,6 +234,149 @@ function M.list_comments()
require("code-review.list").list_comments()
end

--- Reply to comment at cursor position
function M.reply_to_comment_at_cursor()
local bufnr = vim.api.nvim_get_current_buf()
local file = utils.normalize_path(vim.api.nvim_buf_get_name(bufnr))
local row = vim.api.nvim_win_get_cursor(0)[1]

-- Find comments for current line
local line_comments = state.get_comments_at_location(file, row)

if #line_comments == 0 then
vim.notify("No comment at cursor position", vim.log.levels.WARN)
return
end

-- Group comments by thread
local threads = {}
for _, c in ipairs(line_comments) do
local thread_id = c.thread_id or c.id
if not threads[thread_id] then
threads[thread_id] = {
id = thread_id,
root_comment = nil,
comments = {},
}
end
table.insert(threads[thread_id].comments, c)
-- Track root comment
if not c.parent_id then
threads[thread_id].root_comment = c
end
end

-- Select thread if multiple threads exist
local thread_count = vim.tbl_count(threads)

if thread_count == 1 then
-- Single thread case
local selected_thread = threads[next(threads)]
local comment_to_reply = selected_thread.root_comment or selected_thread.comments[1]

-- Show input UI for reply with the same context as the original comment
ui.show_comment_input(function(reply_text)
if reply_text and reply_text ~= "" then
state.add_reply(comment_to_reply.id, reply_text)
vim.notify("Reply added", vim.log.levels.INFO)
end
end, {
file = comment_to_reply.file,
line_start = comment_to_reply.line_start,
line_end = comment_to_reply.line_end,
lines = comment_to_reply.context_lines or {},
}, " Reply to Comment (C-CR to submit) ")
else
-- Create thread selection items
local thread_items = {}
for _, thread in pairs(threads) do
-- Always use the first comment (oldest) for preview
local first_comment = thread.comments[1]
local preview = first_comment.comment:sub(1, 50)
if #first_comment.comment > 50 then
preview = preview .. "..."
end
local item = {
display = string.format("%d. %s (%d comments)", #thread_items + 1, preview, #thread.comments),
thread = thread,
}
table.insert(thread_items, item)
end

-- Show thread selection
vim.ui.select(thread_items, {
prompt = "Select thread to reply to:",
format_item = function(item)
return item.display
end,
}, function(choice)
if not choice then
return
end

local selected_thread = choice.thread

-- Continue with reply process inside callback
local comment_to_reply = selected_thread.root_comment or selected_thread.comments[1]

-- Show input UI for reply with the same context as the original comment
ui.show_comment_input(function(reply_text)
if reply_text and reply_text ~= "" then
state.add_reply(comment_to_reply.id, reply_text)
vim.notify("Reply added", vim.log.levels.INFO)
end
end, {
file = comment_to_reply.file,
line_start = comment_to_reply.line_start,
line_end = comment_to_reply.line_end,
lines = comment_to_reply.context_lines or {},
}, " Reply to Comment (C-CR to submit) ")
end)
end
end

--- Resolve thread at cursor position
function M.resolve_thread_at_cursor()
local bufnr = vim.api.nvim_get_current_buf()
local file = utils.normalize_path(vim.api.nvim_buf_get_name(bufnr))
local row = vim.api.nvim_win_get_cursor(0)[1]

-- Find comments for current line
local line_comments = state.get_comments_at_location(file, row)

if #line_comments == 0 then
vim.notify("No comment at cursor position", vim.log.levels.WARN)
return
end

-- Get unique threads
local threads = {}
for _, c in ipairs(line_comments) do
if c.thread_id then
threads[c.thread_id] = true
end
end

local thread_count = vim.tbl_count(threads)
if thread_count == 0 then
vim.notify("No thread found", vim.log.levels.WARN)
return
elseif thread_count == 1 then
local thread_id = next(threads)
state.resolve_thread(thread_id)
else
-- Multiple threads, let user choose
vim.notify("Multiple threads at this location", vim.log.levels.WARN)
end
end

--- Set review status
---@param status string New status
function M.set_review_status(status)
local review = require("code-review.review")
review.update_status(status)
end

--- Delete comment at cursor position
function M.delete_comment_at_cursor()
local bufnr = vim.api.nvim_get_current_buf()
Expand Down
55 changes: 46 additions & 9 deletions lua/code-review/list.lua
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,56 @@ function M.list_with_quickfix()
return
end

-- Sort by file and line
local sorted_comments = vim.deepcopy(comments)
table.sort(sorted_comments, function(a, b)
if a.file ~= b.file then
return a.file < b.file
-- Build thread tree
local thread = require("code-review.thread")
local threads = thread.build_thread_tree(comments)

-- Get review session for thread status
local review_session = state.get_review_session()
local thread_statuses = review_session and review_session.threads or {}

-- Sort threads by file and line
local sorted_threads = {}
for _, thread_data in pairs(threads) do
table.insert(sorted_threads, thread_data)
end
table.sort(sorted_threads, function(a, b)
local a_root = a.root_comment
local b_root = b.root_comment
if a_root.file ~= b_root.file then
return a_root.file < b_root.file
end
return a.line_start < b.line_start
return a_root.line_start < b_root.line_start
end)

-- Convert to quickfix items
-- Convert to quickfix items with thread grouping
local qf_items = {}
for _, comment in ipairs(sorted_comments) do
table.insert(qf_items, comment_to_qf_item(comment))
for _, thread_data in ipairs(sorted_threads) do
local thread_status = thread_statuses[thread_data.id]
local status_indicator = ""
if thread_status then
if thread_status.status == "resolved" then
status_indicator = "[✓] "
elseif thread_status.status == "outdated" then
status_indicator = "[~] "
else
status_indicator = "[•] "
end
end

-- Add root comment with thread indicator
local root_item = comment_to_qf_item(thread_data.root_comment)
root_item.text = status_indicator .. "THREAD: " .. root_item.text
table.insert(qf_items, root_item)

-- Add replies in linear order
if thread_data.replies then
for _, reply in ipairs(thread_data.replies) do
local reply_item = comment_to_qf_item(reply)
reply_item.text = " └─ " .. reply_item.text
table.insert(qf_items, reply_item)
end
end
end

-- Set quickfix list
Expand Down
Loading