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
5 changes: 5 additions & 0 deletions .claude/review-lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,8 @@
- **問題**: `parse_name_status`の`C`(copy)分岐があることを根拠に「copied fileも旧パスと比較する」と書いたが、実際の入力元`diff.get_name_status`は`git diff -M`でrenameしか検出しないため、local modeではcopyが`added`になり分岐に到達しなかった
- **対策**: パーサーの分岐を根拠に挙動を文書化するときは、呼び出し元のコマンドやAPIがその形式を実際に出力するか(gitのフラグ、APIのstatus値)まで遡って確認する。到達しない分岐はmode限定で書くか、記載から外す
- **該当箇所**: lua/fude/local/session.lua, doc/fude.txt

### エッジケース: カーソル連動の表示が「同じ行ならskip」のキャッシュで再描画に追従しない (PR #237, 2026-10-10)
- **問題**: 複数行コメントの範囲強調を`update_inline_hint`(CursorMovedで呼ばれ、同じbuffer/lineなら早期return)に載せたため、カーソルを動かさないままreloadや再anchorで`comment_map`が変わると、削除・移動された範囲の強調と開始マーカーが次のカーソル移動まで残った
- **対策**: カーソル位置をキーに「表示済みならskip」するextmarkを足すときは、そのデータを書き換える描画経路(`refresh_extmarks`等)からも無効化・再構築する。他windowを`nvim_win_call`で描画する経路があるので、再構築は表示中のバッファに限り、カーソルのあるwindowから表示を移さない
- **該当箇所**: lua/fude/ui/extmarks.lua
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ luacov.report.out

# Local review mode store (per-worktree review comments)
.fude/

# Neovim log written to cwd when the state dir is unwritable (e.g. headless test runs)
nvim.log
8 changes: 4 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,15 @@ require("fude").setup({
label = "[resolved]", -- Label string (comment browser / viewer / virtual text)
hl_group = "DiagnosticOk", -- Highlight group for resolved labels
},
-- Multi-line comment range display. A multi-line comment is shown on its
-- last line; inline boxes are titled with the range (e.g. "Comment L20-L29").
-- No background or sign column is used. Set a field to false to disable it.
comment_range = {
number_hl = "DiagnosticInfo", -- Line number highlight on the range (needs 'number' or 'relativenumber')
-- While the cursor is on the comment line:
cursor_number_hl = "DiagnosticWarn", -- replaces number_hl on the range
start_marker_hl = "DiagnosticHint", -- "↓ comment L20-L29" at the end of the first line
},
-- Side panel options
sidepanel = {
width = 40, -- Panel width in columns
Expand Down
35 changes: 35 additions & 0 deletions doc/fude.txt
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,15 @@ File opening position *fude-file-position*
|:FudeReviewToggleCommentStyle| are detected, the hint shows those
keybindings with `<leader>` notation. Otherwise, it shows the command
names. The hint disappears when the cursor moves to a non-comment line.

A multi-line comment is shown on its last line, so its first line is
marked separately: the inline box of the thread's head comment is
titled with the range (e.g. `Comment L20-L29`), the line numbers of the
range are highlighted with `comment_range.number_hl`, and while the
cursor is on the comment line the range's line numbers switch to
`comment_range.cursor_number_hl` and its first line gets a
`↓ comment L20-L29` marker at the end. None of these use a background
or the sign column, so diff colors and gitsigns stay visible.
Requires an active review session.

:FudeReviewToggleResolved *:FudeReviewToggleResolved*
Expand Down Expand Up @@ -1014,6 +1023,12 @@ Default configuration: >lua
label = "[resolved]", -- Label string
hl_group = "DiagnosticOk", -- Highlight group for resolved labels
},
-- Multi-line comment range display (false disables a field)
comment_range = {
number_hl = "DiagnosticInfo", -- Line number highlight on the range
cursor_number_hl = "DiagnosticWarn", -- Same, while on the comment line
start_marker_hl = "DiagnosticHint", -- First-line marker, while on the comment line
},
})
<

Expand Down Expand Up @@ -1279,6 +1294,26 @@ Options:
`resolved.hl_group` Highlight group for resolved labels
(default: "DiagnosticOk").

`comment_range.number_hl`
Highlight group for the line numbers of every line a
multi-line comment covers (default: "DiagnosticInfo").
Applied in both comment styles. Only the number column is
colored, so it takes no sign column slot and does not
override diff colors; it is invisible without 'number' or
'relativenumber'. Set to false to disable.

`comment_range.cursor_number_hl`
Highlight group that replaces `comment_range.number_hl` on
the range while the cursor is on the comment's line (its
last line); removed when the cursor leaves it
(default: "DiagnosticWarn"). Set to false to disable.

`comment_range.start_marker_hl`
Highlight group for the `↓ comment L20-L29` marker shown at
the end of the range's first line while the cursor is on
the comment's line (default: "DiagnosticHint"). Set to
false to disable.

`auto_reload.enabled`
Enable automatic periodic reload of review data
(default: false). When enabled, comments, changed files,
Expand Down
1 change: 1 addition & 0 deletions lua/fude/comments.lua
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ M.build_review_comment_object = data.build_review_comment_object
M.merge_pending_into_comments = data.merge_pending_into_comments
M.pending_comments_to_array = data.pending_comments_to_array
M.get_comment_line_range = data.get_comment_line_range
M.get_multiline_ranges = data.get_multiline_ranges
M.get_reply_target_id = data.get_reply_target_id
M.build_submit_choices = data.build_submit_choices

Expand Down
19 changes: 19 additions & 0 deletions lua/fude/comments/data.lua
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,25 @@ function M.get_comment_line_range(comment)
return start_line, end_line
end

--- Collect the distinct line ranges of the multi-line comments in a list.
--- Single-line comments are skipped: their range is the line the comment is
--- shown on, so marking it adds nothing.
--- @param comments table[] comment objects
--- @return table[] ranges { start_line, end_line } in first-seen order
function M.get_multiline_ranges(comments)
local ranges = {}
local seen = {}
for _, comment in ipairs(comments) do
local start_line, end_line = M.get_comment_line_range(comment)
local key = start_line .. ":" .. end_line
if start_line < end_line and not seen[key] then
seen[key] = true
table.insert(ranges, { start_line = start_line, end_line = end_line })
end
end
return ranges
end

--- Get the reply target ID for a comment.
--- GitHub API doesn't allow replying to replies, so we need to find the top-level comment.
--- @param comment_id number the comment ID
Expand Down
10 changes: 10 additions & 0 deletions lua/fude/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ M.defaults = {
label = "[resolved]", -- Label string for resolved threads
hl_group = "DiagnosticOk", -- Highlight group for resolved labels
},
-- Show which lines a multi-line comment covers. None of these use a background
-- or the sign column, so diff colors and gitsigns stay visible. Set a field to
-- false to disable it.
comment_range = {
-- Line number highlight on every line of the range (needs 'number' or 'relativenumber').
number_hl = "DiagnosticInfo",
-- While the cursor is on the comment's line (the range's last line):
cursor_number_hl = "DiagnosticWarn", -- replaces number_hl on the range
start_marker_hl = "DiagnosticHint", -- "↓ comment L20-L29" at the end of the first line
},
keymaps = {
create_comment = "<leader>Rc",
view_comments = "<leader>Rv",
Expand Down
2 changes: 2 additions & 0 deletions lua/fude/ui.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ local ref_ns = vim.api.nvim_create_namespace("fude_refs")

-- Re-export format functions (facade)
M.calculate_float_dimensions = format.calculate_float_dimensions
M.format_line_range = format.format_line_range
M.build_range_start_marker = format.build_range_start_marker
M.format_comments_for_display = format.format_comments_for_display
M.build_comments_float_title = format.build_comments_float_title
M.normalize_check = format.normalize_check
Expand Down
97 changes: 97 additions & 0 deletions lua/fude/ui/extmarks.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
local M = {}
local config = require("fude.config")
local data = require("fude.comments.data")
local drafts = require("fude.drafts")
local format = require("fude.ui.format")
local util = require("fude.util")

--- Get the namespace ID for flash/highlight extmarks.
Expand Down Expand Up @@ -77,6 +79,31 @@ function M.clear_comment_line_highlight()
comment_line_highlight.extmark_ids = {}
end

--- Highlight the line numbers of every line in the ranges, clamped to the
--- buffer (a local comment can point past EOF until it is marked outdated).
--- @param buf number buffer handle
--- @param ns number namespace
--- @param ranges table[] { start_line, end_line } (1-indexed, inclusive)
--- @param hl_group string highlight group for the number column
--- @param priority number extmark priority (a higher one wins on the same line)
--- @return number[] extmark ids
local function highlight_range_numbers(buf, ns, ranges, hl_group, priority)
local ids = {}
local line_count = vim.api.nvim_buf_line_count(buf)
for _, range in ipairs(ranges) do
for line = math.max(1, range.start_line), math.min(range.end_line, line_count) do
local ok, id = pcall(vim.api.nvim_buf_set_extmark, buf, ns, line - 1, 0, {
number_hl_group = hl_group,
priority = priority,
})
if ok then
table.insert(ids, id)
end
end
end
return ids
end

--- Render end-of-line virtualText indicators (comment count, pending, resolved)
--- for a set of comments on a line. Shared by the virtualText comment style and
--- by the inline style's fallback for resolved comments whose box is hidden by
Expand Down Expand Up @@ -142,6 +169,7 @@ function M.refresh_extmarks()
-- unrelated lines; clear instead.
if require("fude.local.session").in_commit_scope() then
vim.api.nvim_buf_clear_namespace(buf, state.ns_id, 0, -1)
M.rebuild_inline_hint(buf)
return
end

Expand All @@ -163,10 +191,17 @@ function M.refresh_extmarks()
if style == "inline" then
inline_opts = config.opts.inline or {}
end
local number_hl = (config.opts.comment_range or {}).number_hl

for _, line in ipairs(comment_lines) do
local comments = comments_mod.get_comments_at(rel_path, line)

-- Mark the line numbers a multi-line comment covers. Comments are drawn on
-- their last line only, so without this the first line is not visible.
if number_hl then
highlight_range_numbers(buf, state.ns_id, data.get_multiline_ranges(comments), number_hl, 40)
end

if style == "inline" then
-- Inline mode: display full comment content below the line.
-- When FudeReviewToggleResolved is off, resolved comments do not get an
Expand Down Expand Up @@ -235,6 +270,8 @@ function M.refresh_extmarks()
priority = 44,
})
end

M.rebuild_inline_hint(buf)
end

--- Clear all extmarks for a specific buffer.
Expand Down Expand Up @@ -293,6 +330,8 @@ local current_hint = {
buf = nil,
line = nil,
extmark_id = nil,
-- Range feedback (line numbers, start markers) for the comments on the hinted line
range_ids = {},
}

-- Cache for keymaps (avoid repeated keymap lookups on CursorMoved)
Expand All @@ -308,9 +347,51 @@ function M.clear_inline_hint()
if current_hint.buf and current_hint.extmark_id then
pcall(vim.api.nvim_buf_del_extmark, current_hint.buf, get_hint_ns(), current_hint.extmark_id)
end
if current_hint.buf then
for _, id in ipairs(current_hint.range_ids) do
pcall(vim.api.nvim_buf_del_extmark, current_hint.buf, get_hint_ns(), id)
end
end
current_hint.buf = nil
current_hint.line = nil
current_hint.extmark_id = nil
current_hint.range_ids = {}
end

--- Mark the multi-line ranges of the comments on the cursor line: emphasize
--- their line numbers and put a marker at the end of each range's first line.
--- A comment is drawn on the last line of its range, so this shows where it
--- starts. No background highlight is used, so diff colors stay visible.
--- @param buf number buffer handle
--- @param ns number hint namespace
--- @param comments table[] comments on the cursor line
--- @return number[] extmark ids
local function mark_cursor_ranges(buf, ns, comments)
local range_opts = config.opts.comment_range or {}
local ranges = data.get_multiline_ranges(comments)
local ids = {}
if range_opts.cursor_number_hl then
-- Above refresh_extmarks' number_hl (40) so it replaces it on these lines
ids = highlight_range_numbers(buf, ns, ranges, range_opts.cursor_number_hl, 45)
end
if range_opts.start_marker_hl then
local line_count = vim.api.nvim_buf_line_count(buf)
for _, range in ipairs(ranges) do
if range.start_line >= 1 and range.start_line <= line_count then
local ok, id = pcall(vim.api.nvim_buf_set_extmark, buf, ns, range.start_line - 1, 0, {
virt_text = {
{ " " .. format.build_range_start_marker(range.start_line, range.end_line), range_opts.start_marker_hl },
},
virt_text_pos = "eol",
priority = 200,
})
if ok then
table.insert(ids, id)
end
end
end
end
return ids
end

--- Convert internal keymap lhs to human-readable format.
Expand Down Expand Up @@ -457,11 +538,27 @@ function M.update_inline_hint()
current_hint.buf = buf
current_hint.line = cursor_line
current_hint.extmark_id = extmark_id
current_hint.range_ids = mark_cursor_ranges(buf, ns, comments)
Comment thread
Copilot marked this conversation as resolved.
end

-- Autocmd group for inline hint
local hint_augroup = nil

--- Rebuild the cursor-line hint and range feedback after a buffer's comments
--- were re-rendered. update_inline_hint skips a line it already marked, so a
--- reload or re-anchor would otherwise leave stale ranges until the cursor
--- moves. Only a hint already shown in `buf` is rebuilt: refresh_visible_extmarks
--- re-renders other windows through nvim_win_call, and rebuilding there would
--- move the hint out of the window the user is in.
--- @param buf number buffer whose comments were re-rendered
function M.rebuild_inline_hint(buf)
if current_hint.buf ~= buf then
return
end
M.clear_inline_hint()
M.update_inline_hint()
Comment on lines +555 to +559
end

--- Setup autocmd for inline hint updates.
function M.setup_inline_hint_autocmd()
if hint_augroup then
Expand Down
25 changes: 25 additions & 0 deletions lua/fude/ui/format.lua
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ function M.build_comment_separator(width)
return string.rep("-", math.max(1, math.floor(width)))
end

--- Format a multi-line comment range as "L20-L29".
--- @param start_line number|nil 1-indexed first line
--- @param end_line number|nil 1-indexed last line
--- @return string|nil label, nil for a single line or an invalid range
function M.format_line_range(start_line, end_line)
if type(start_line) ~= "number" or type(end_line) ~= "number" or start_line >= end_line then
return nil
end
return string.format("L%d-L%d", start_line, end_line)
end

--- Build the end-of-line marker shown on the first line of a multi-line
--- comment range while the cursor is on the comment ("↓ comment L20-L29").
--- The arrow points down because the comment is drawn on the range's last line.
--- @param start_line number 1-indexed first line
--- @param end_line number 1-indexed last line
--- @return string marker text ("" for a single line or an invalid range)
function M.build_range_start_marker(start_line, end_line)
local range = M.format_line_range(start_line, end_line)
if not range then
return ""
end
return "↓ comment " .. range
end

--- Build status badges for a comment header (" [agent]", " [approved]", ...).
--- Local review comments carry author_type ("human"|"agent"); plain GitHub
--- comments get "". A submitted review body (`review_state`, see
Expand Down
18 changes: 13 additions & 5 deletions lua/fude/ui/inline.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
local M = {}

local data = require("fude.comments.data")
local format = require("fude.ui.format")
local util = require("fude.util")

Expand Down Expand Up @@ -83,12 +84,19 @@ function M.format_comments_for_inline(comments, format_date_fn, opts)
-- `is_resolved` is a thread-level state applied to every comment in the
-- thread, so the `[resolved thread]` label is shown only on the thread's
-- head comment (the oldest one, which has no in_reply_to_id) instead of
-- repeating on every reply box.
local label = " Comment "
-- repeating on every reply box. The line range of a multi-line comment
-- (` Comment L20-L29 `) is thread-level too, so it follows the same rule.
local is_head = util.is_null(comment.in_reply_to_id)
local title = "Comment"
local range = is_head and format.format_line_range(data.get_comment_line_range(comment))
if range then
title = title .. " " .. range
end
local label = " " .. title .. " "
if is_pending then
label = " Comment [pending] "
elseif comment.is_resolved and util.is_null(comment.in_reply_to_id) then
label = " Comment [resolved thread] "
label = " " .. title .. " [pending] "
elseif comment.is_resolved and is_head then
label = " " .. title .. " [resolved thread] "
end
local corner_width = 2 -- ╭ and ╮ are 1 cell each
local left_dash_width = 1 -- ─ after ╭
Expand Down
Loading
Loading