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 @@ -24,6 +24,11 @@
- **対策**: 呼び出し側の規約に依存せず、関数冒頭で空入力を弾いてエラーcallbackする。配列をJSONエンコードする関数では空テーブルがオブジェクトになるLuaの仕様を常に考慮する
- **該当箇所**: lua/fude/gh.lua

### エッジケース: 集約判定は部分集合ではなく論理的対象の全体に評価する (PR #163, 2026-07-23)
- **問題**: inline modeでresolvedコメントのboxを隠す際、行末フォールバックの`[resolved]`判定を「隠した部分集合(hidden_resolved)」だけで行っていたため、同一行に未解決コメントが混在しても`all_comments_resolved`が常にtrueになり、誤って`[resolved]`が表示された。「この行は全部resolvedか」という集約述語に行の一部だけを渡して意味が変わっていた
- **対策**: 集約述語(all_X / every_X 等)は、その述語が論理的に対象とする集合の全体(行の全コメント等)に対して評価する。フィルタ後の部分集合を渡すと述語の意味が壊れる。あわせて、要素を振り分けるフィルタ条件と集約述語は同じフィールド(`is_resolved`)で判定し、食い違いを構造的に排除する
- **該当箇所**: lua/fude/ui/extmarks.lua

### テスト: 実クロックと比較されるfixture日付の時限爆弾 (PR #155, 2026-07-06)
- **問題**: retention pruneを通る実ロードパスのテストで、fixtureの`saved_at`をハードコードした過去日付にしていたため、日付経過でretention window(30日)から外れテストが壊れた。動的な`os.date()`(現在時刻)への修正も「実行時刻依存で再現性が落ちる」とレビュー指摘を受けた
- **対策**: 実時刻(`os.time()`)と比較される経路を通るfixtureのタイムスタンプは、固定の十分未来の日付(例: `2126-01-01T00:00:00Z`)を使う。過去日付のハードコードは時限爆弾、現在時刻の動的生成は再現性低下。時刻を注入できる純粋関数(`prune(t, now, days)`等)のテストは固定`now`を渡して書く
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ PR code review inside Neovim. Review GitHub pull requests without leaving your e
| `:FudeCopyPRURL` | Copy PR URL to clipboard |
| `:FudeReviewReload` | Reload review data from GitHub |
| `:FudeReviewToggleCommentStyle` | Toggle comment display style (virtualText/inline) |
| `:FudeReviewToggleResolved` | Toggle visibility of resolved comments in the editor |
| `:FudeReviewToggleGitsigns` | Toggle gitsigns between PR base and HEAD |
| `:FudeReviewPanel` | Toggle review side panel |
| `:FudeReviewToggleFileTree` | Toggle side panel files between flat list and tree |
Expand Down Expand Up @@ -217,6 +218,8 @@ require("fude").setup({
-- comment browser, comment viewer, and virtual text with `label`. Inline
-- comment boxes instead show a fixed "[resolved thread]" on the thread's
-- head (oldest) comment only.
-- :FudeReviewToggleResolved hides resolved comments' inline boxes at runtime;
-- hidden ones fall back to the virtual text indicator (e.g. "[resolved] #1").
-- Set show = false to hide all resolved labels. (The review-threads fetch
-- is shared with outdated detection; it is skipped only when outdated.show
-- is also false and no pending review exists.)
Expand Down
25 changes: 25 additions & 0 deletions doc/fude.txt
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,29 @@ Using lazy.nvim: >lua
names. The hint disappears when the cursor moves to a non-comment line.
Requires an active review session.

:FudeReviewToggleResolved *:FudeReviewToggleResolved*
Toggle whether resolved comments show their full inline comment box
(only relevant with the "inline" comment style). When hidden, a
resolved comment falls back to the same end-of-line indicator the
"virtualText" style uses (e.g. `[resolved] #1`), so the line stays
marked. Navigation (`]c` / `[c`), the comment viewer, the
"virtualText" style, and the comment browser
(|:FudeReviewListComments|) always show resolved threads regardless
of this toggle.

Covers threads resolved on GitHub ("Resolve conversation") and, in
local review mode, threads resolved with |:FudeReviewResolve|.
This command only changes visibility; use |:FudeReviewResolve| to
change a thread's resolved state (local review mode).

The visibility is stored in the session state, survives
|:FudeReviewReload| and auto-reloads, and resets to visible when
review mode is stopped.

When `resolved.show = false`, resolved state is not applied at all
and this command only shows a warning.
Requires an active review session.

:FudeReviewPanel *:FudeReviewPanel*
Toggle the review side panel. The panel is a sidebar split window
that shows two sections:
Expand Down Expand Up @@ -778,6 +801,8 @@ Options:
review-threads fetch is shared with outdated comment
detection, so it is skipped only when `outdated.show` is
also false and no pending review exists.
Visibility of resolved comments' inline boxes can be
toggled at runtime with |:FudeReviewToggleResolved|.

`resolved.label` Label text shown for resolved threads
(default: "[resolved]"). Appears in the comment browser
Expand Down
13 changes: 13 additions & 0 deletions lua/fude/comments.lua
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ local function has_review_target(state)
return state.active and (state.pr_number ~= nil or state.local_session ~= nil)
end

--- Toggle editor visibility of resolved comments.
--- Only affects inline comment boxes (the virt_lines rendered in "inline"
--- comment style): refresh_extmarks skips resolved comments when hidden.
--- comment_map is left intact, so navigation, the comment viewer, virtualText
--- indicators, and the comment browser (FudeReviewListComments) always show
--- resolved threads regardless of this toggle.
Comment on lines +59 to +63
--- @return boolean the new visibility
function M.toggle_resolved_visibility()
local visible = config.toggle_show_resolved()
ui.refresh_extmarks()
return visible
end

--- Get comments at a specific file and line.
--- @param rel_path string repo-relative file path
--- @param line number line number
Expand Down
9 changes: 7 additions & 2 deletions lua/fude/comments/sync.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ local is_null = require("fude.util").is_null
--- also propagated to replies via in_reply_to_id.
--- @param comments table[] array of comment objects
--- @param thread_info_map table<number, table> { [root_comment_id] = { is_outdated, is_resolved, original_line } }
--- @param opts table { apply_outdated = boolean, apply_resolved = boolean }
--- @param opts table { apply_outdated = boolean, apply_resolved = boolean, pending_review_id = number|nil }
local function apply_thread_info(comments, thread_info_map, opts)
-- Fallback index for threads whose root comment was deleted on GitHub: the
-- GraphQL "root" is then the earliest surviving reply, whose in_reply_to_id
Expand All @@ -31,7 +31,11 @@ local function apply_thread_info(comments, thread_info_map, opts)
if opts.apply_outdated and info and info.is_outdated then
c.is_outdated = true
end
if opts.apply_resolved then
-- Never mark unsubmitted pending comments as resolved: the user's own
-- pending reply on a resolved thread still needs attention, so the line
-- must not render as fully resolved (nor be hidden by the visibility toggle).
local is_pending = opts.pending_review_id ~= nil and c.pull_request_review_id == opts.pending_review_id
if opts.apply_resolved and not is_pending then
local thread_info = info
if not thread_info and not is_null(c.in_reply_to_id) then
thread_info = thread_info_map[c.in_reply_to_id] or info_by_parent[c.in_reply_to_id]
Expand Down Expand Up @@ -107,6 +111,7 @@ local function fetch_comments(callback, opts)
apply_thread_info(comments, thread_info_map, {
apply_outdated = need_outdated,
apply_resolved = need_resolved,
pending_review_id = state.pending_review_id,
})
end
apply(comments)
Expand Down
20 changes: 20 additions & 0 deletions lua/fude/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ M.state = {
github_user = nil, -- Authenticated GitHub username (for ownership check)
comment_browser = nil, -- 3-pane comment browser window state
current_comment_style = nil, -- Runtime override for comment_style (nil = use opts.comment_style)
show_resolved = nil, -- Runtime override for resolved comment visibility in the editor (nil = visible)
reload_timer = nil, -- vim.uv.new_timer() handle for auto-reload
reloading = false, -- Guard flag to prevent concurrent reloads
gitsigns_reset = false, -- true: HEAD表示(一時的に元のワークツリー状態)、false: PRベース表示
Expand Down Expand Up @@ -209,6 +210,7 @@ function M.reset_state()
github_user = nil,
comment_browser = nil,
current_comment_style = nil,
show_resolved = nil,
reload_timer = nil,
reloading = false,
gitsigns_reset = false,
Expand Down Expand Up @@ -273,4 +275,22 @@ function M.toggle_comment_style()
return new_style
end

--- Get whether resolved comments are currently visible in the editor.
--- Returns the runtime override if set, otherwise defaults to visible.
--- Editor-only: the comment browser always shows resolved threads.
--- @return boolean
function M.get_show_resolved()
if M.state.show_resolved ~= nil then
return M.state.show_resolved
end
return true
end

--- Toggle editor visibility of resolved comments.
--- @return boolean the new visibility
function M.toggle_show_resolved()
M.state.show_resolved = not M.get_show_resolved()
return M.state.show_resolved
end

return M
133 changes: 83 additions & 50 deletions lua/fude/ui/extmarks.lua
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,58 @@ function M.clear_comment_line_highlight()
comment_line_highlight.extmark_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
--- FudeReviewToggleResolved, so both paths produce an identical indicator.
--- @param buf number buffer handle
--- @param line number 1-indexed line number
--- @param comments table[] comments on the line
local function render_virt_text_indicators(buf, line, comments)
local state = config.state
local submitted_count = 0
local has_pending = false
local all_resolved = util.all_comments_resolved(comments)
for _, c in ipairs(comments) do
if state.pending_review_id and c.pull_request_review_id == state.pending_review_id then
has_pending = true
else
submitted_count = submitted_count + 1
end
end

if submitted_count > 0 then
pcall(vim.api.nvim_buf_set_extmark, buf, state.ns_id, line - 1, 0, {
virt_text = {
{ string.format(" %s%d", config.opts.signs.comment, submitted_count), config.opts.signs.comment_hl },
},
virt_text_pos = "eol",
priority = 50,
})
end
if has_pending then
pcall(vim.api.nvim_buf_set_extmark, buf, state.ns_id, line - 1, 0, {
virt_text = {
{ " " .. config.opts.signs.pending, config.opts.signs.pending_hl },
},
virt_text_pos = "eol",
priority = 45,
})
end
-- Resolved indicator: only when every thread on the line is resolved
-- (a partially resolved line still needs attention).
if all_resolved then
local resolved_opts = config.opts.resolved or {}
pcall(vim.api.nvim_buf_set_extmark, buf, state.ns_id, line - 1, 0, {
virt_text = {
{ " " .. (resolved_opts.label or "[resolved]"), resolved_opts.hl_group or "DiagnosticOk" },
},
virt_text_pos = "eol",
priority = 43,
})
end
end

--- Refresh extmarks (virtual text) for the current buffer.
function M.refresh_extmarks()
local state = config.state
Expand All @@ -98,78 +150,59 @@ function M.refresh_extmarks()
local comment_lines = comments_mod.get_comment_lines(rel_path)

local style = config.get_comment_style()
local inline_opts = config.opts.inline or {}
local inline_opts
if style == "inline" then
inline_opts = config.opts.inline or {}
end

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

if style == "inline" then
-- Inline mode: display full comment content below the line
-- Build arrays only when needed for inline display
local all_comments_for_display = {}
-- Inline mode: display full comment content below the line.
-- When FudeReviewToggleResolved is off, resolved comments do not get an
-- inline box. If that leaves the line with nothing to show, it falls back
-- to the same EOL virtualText indicator the virtualText style would show
-- (e.g. `[resolved] 🗒️1`) so the line stays marked. Pending comments are
-- never resolved, so they always keep their inline box.
Comment on lines +162 to +167
local show_resolved = config.get_show_resolved()
local box_comments = {}
local hidden_resolved = {}
for _, c in ipairs(comments) do
if state.pending_review_id and c.pull_request_review_id == state.pending_review_id then
local pc = vim.tbl_extend("force", {}, c)
pc.is_pending = true
table.insert(all_comments_for_display, pc)
table.insert(box_comments, pc)
elseif show_resolved or not c.is_resolved then
-- Read `is_resolved` alone (local mode normalizes `resolved` onto it,
-- gated by resolved.show), matching util.all_comments_resolved so the
-- box/fallback split and the `[resolved]` label never disagree.
table.insert(box_comments, c)
else
table.insert(all_comments_for_display, c)
table.insert(hidden_resolved, c)
end
end

if #all_comments_for_display > 0 then
if #box_comments > 0 then
local inline = require("fude.ui.inline")
local result = inline.format_comments_for_inline(all_comments_for_display, config.format_date, inline_opts)
local result = inline.format_comments_for_inline(box_comments, config.format_date, inline_opts)
pcall(vim.api.nvim_buf_set_extmark, buf, state.ns_id, line - 1, 0, {
virt_lines = result.virt_lines,
virt_lines_above = false,
priority = 50,
})
elseif #hidden_resolved > 0 then
-- Only reached when the line has no box to show, i.e. every comment on
-- it is a hidden resolved one. hidden_resolved therefore equals the
-- whole line, so render_virt_text_indicators judges the count and the
-- `[resolved]` label over all of the line's comments (matching the
-- virtualText style). On a mixed line the box above already marks it,
-- so no fallback is rendered.
render_virt_text_indicators(buf, line, hidden_resolved)
end
else
-- virtualText mode: display indicators at end of line (original behavior)
-- Only compute counts, avoid building arrays
local submitted_count = 0
local has_pending = false
local all_resolved = util.all_comments_resolved(comments)
for _, c in ipairs(comments) do
if state.pending_review_id and c.pull_request_review_id == state.pending_review_id then
has_pending = true
else
submitted_count = submitted_count + 1
end
end

if submitted_count > 0 then
pcall(vim.api.nvim_buf_set_extmark, buf, state.ns_id, line - 1, 0, {
virt_text = {
{ string.format(" %s%d", config.opts.signs.comment, submitted_count), config.opts.signs.comment_hl },
},
virt_text_pos = "eol",
priority = 50,
})
end
if has_pending then
pcall(vim.api.nvim_buf_set_extmark, buf, state.ns_id, line - 1, 0, {
virt_text = {
{ " " .. config.opts.signs.pending, config.opts.signs.pending_hl },
},
virt_text_pos = "eol",
priority = 45,
})
end
-- Resolved indicator: only when every thread on the line is resolved
-- (a partially resolved line still needs attention).
if all_resolved then
local resolved_opts = config.opts.resolved or {}
pcall(vim.api.nvim_buf_set_extmark, buf, state.ns_id, line - 1, 0, {
virt_text = {
{ " " .. (resolved_opts.label or "[resolved]"), resolved_opts.hl_group or "DiagnosticOk" },
},
virt_text_pos = "eol",
priority = 43,
})
end
render_virt_text_indicators(buf, line, comments)
end
end

Expand Down
14 changes: 14 additions & 0 deletions plugin/fude.lua
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,20 @@ vim.api.nvim_create_user_command("FudeReviewToggleCommentStyle", function()
require("fude.ui").refresh_extmarks()
end, { desc = "Toggle comment display style (virtualText/inline)" })

vim.api.nvim_create_user_command("FudeReviewToggleResolved", function()
local config = require("fude.config")
if not config.state.active then
vim.notify("fude.nvim: Not active", vim.log.levels.WARN)
return
end
if config.opts.resolved and config.opts.resolved.show == false then
vim.notify("fude.nvim: Resolved display is disabled (resolved.show = false)", vim.log.levels.WARN)
return
end
local visible = require("fude.comments").toggle_resolved_visibility()
vim.notify("fude.nvim: Resolved comments: " .. (visible and "shown" or "hidden"), vim.log.levels.INFO)
end, { desc = "Toggle visibility of resolved comments in the editor" })

vim.api.nvim_create_user_command("FudeReviewToggleGitsigns", function()
require("fude").toggle_gitsigns()
end, { desc = "Toggle gitsigns between PR base and HEAD" })
Expand Down
Loading
Loading