diff --git a/.claude/review-lessons.md b/.claude/review-lessons.md index 929d466..e02f038 100644 --- a/.claude/review-lessons.md +++ b/.claude/review-lessons.md @@ -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 diff --git a/.gitignore b/.gitignore index 0aa7a02..08a544f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index df174e8..766ab17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,17 +41,17 @@ All plugin code lives under `lua/fude/`. The plugin entry point is `plugin/fude. - **`comments/local_sync.lua`** — Local comment backend mirroring `comments/sync.lua`'s external shape (`load_comments`, `reply_to_comment`, `edit_comment`, `delete_comment` with identical callback signatures) plus `create_comment` (with best-effort `context` capture), `move_comments` (batch re-anchor), `toggle_resolved`, and `set_viewed` (local viewed state, no GitHub round-trip). `load_comments` also populates `state.viewed_files` from the materialized viewed map, and runs `store.reanchor` (reading current file lines, preferring loaded buffers) to recover drifted comments, persisting confident re-anchors as `move` events before `apply_outdated`. After `apply_outdated` it normalizes the local thread-level `resolved` flag onto the display-facing `is_resolved`, gated by `resolved.show` (mirroring how `sync.lua` only sets `is_resolved` at fetch time), so the whole display layer reads `is_resolved` alone (the resolved-visibility toggle keys off `is_resolved`, so locally resolved threads are covered too). All operations are synchronous JSONL appends followed by one re-materialize into `state.comments`/`state.comment_map`; `comments.lua` dispatches to this backend when `state.review_mode == "local"`. While the session is in the commit scope, every mutation (`create_comment`/`reply_to_comment`/`edit_comment`/`delete_comment`/`move_comments`/`toggle_resolved`, not `set_viewed`) fails early with `COMMIT_SCOPE_ERROR` — enforced here because `ui/comment_browser.lua` calls the backend directly — and `load_comments` returns early with empty `state.comments`/`state.comment_map` (viewed state kept), clearing the comment extmarks of *every* buffer (`ui.clear_all_extmarks`) and the cursor-following hint (`extmarks.clear_inline_hint`, its own namespace), tearing down every tracking mark (`tracker.teardown`) and closing the reply/edit window, comment browser and viewer/input floats (`ui.close_comment_ui`), since the files on disk are the checked-out commit, not the working tree — this also keeps the side panel and picker comment counts empty, matching the "no comments shown" contract; the next load after leaving the scope restores them, and `apply_scope` re-renders all visible windows via `ui.refresh_visible_extmarks`. - **`preview.lua`** — Manages the side-by-side diff preview window. Creates a scratch buffer with base branch content, opens it in a vsplit, and enables `diffthis` on both windows. Uses `noautocmd` to prevent BufEnter cascades. The `opening` flag guards against re-entrant calls. `open_preview` maps the source buffer with `local/session.relative_path` and passes the local session's worktree root to `diff.get_base_content(ref, path, cwd?)`, so a `:cd` out of the worktree does not leave the preview closed after a scope switch (outside local mode both fall back to the cwd-based lookups). The base content is read from the path returned by the pure `find_base_path(rel_path, changed_files)` — the entry's `previous_path` for a renamed file (or a copied one in GitHub mode, when the API reports it), else `rel_path` — retrying `rel_path` when the old path is missing at the base ref (the full PR scope reads the base branch tip, which may have dropped it); the preview buffer is named `[base] `. It rejects a source window holding a nofile buffer (e.g. the side panel) — `to_repo_relative` cannot reject such buffer names when cwd is inside the repo, so without the guard `:FudeReviewDiff` from the panel would vsplit the panel itself and wedge `state.source_win` to it. `on_buf_enter` skips the rebuild when the preview already targets the same source window and buffer (`is_preview_current`, backed by `state.preview_source_buf`): rebuilding runs `diffoff!`/`diffthis`, which resets `foldenable`/`foldlevel` and the preview window's options, so re-entering the same file (closing a comment float, moving between the two panes) must not discard the user's fold/wrap changes. Explicit rebuild paths (`toggle_diff`, scope changes) call `close_preview` first and are unaffected. Rebuilds save the source view and fold options, run diffupdate before restoring them, and therefore do not undo file-opening positions. The default `diffopt` includes `followwrap` because `diffthis` otherwise forces `nowrap`, and `linematch:0` because the second-stage line alignment splits a heavily rewritten block into many small hunks with fillers scattered on both sides (a later duplicate key wins, so this also overrides Neovim 0.11+'s built-in `linematch:40`). It deliberately sets no `algorithm:`, leaving Neovim's default myers, which splits hunks the same way git and the GitHub web view do. - **`comments.lua`** — Facade module re-exporting `comments/data.lua`, `comments/sync.lua`, and `comments/pickers.lua`. Contains comment navigation (`next_comment`/`prev_comment`), creation (`create_comment`/`suggest_change`), viewing (`view_comments`), reply (`reply_to_comment`), editing (`edit_comment`), deletion (`delete_comment`), and thread resolution (`toggle_resolve(comment_id?)` — the given comment's thread, else the first comment on the current line; local mode toggles the JSONL state via `local_sync.toggle_resolved`, GitHub mode calls `sync.toggle_resolved` after refusing a thread whose root is in the user's pending review — `is_pending_comment`, or `find_pending_key` for the first sync before `pending_review_id` arrives — which GitHub has not published). Dispatches mutations to `comments/sync.lua` (GitHub) or `comments/local_sync.lua` (local JSONL) by `state.review_mode`. Local create/suggest (module-local `create_local_comment`) uses the same draft wiring as the GitHub flow (prefill, save/discard on close, removal after the comment is saved); since the local save is synchronous, the draft is removed without a snapshot check. In the local commit scope (`local/session.in_commit_scope()`), the module-local `blocked_by_commit_scope` refuses creation, suggest, reply, edit, delete, resolve, the viewer, next/prev navigation and `list_comments` with a WARN (`local_sync.COMMIT_SCOPE_ERROR`), because the buffer shows a checked-out past commit rather than the working tree comments anchor to. In GitHub mode, `create_comment`/`suggest_change` pass a `pick_submit_kind` to the input so `` asks (via `vim.ui.select` over `build_submit_choices`) whether to add the comment to the pending review or post it as a single comment (`sync.create_single_comment`); the choice is built at `` time from `sync.has_pending_review()`, and with a pending review only the review choice exists, so no prompt is shown. Also provides ownership helpers: `is_own_comment`, `is_pending_comment`, `find_pending_key`, and `toggle_resolved_visibility()` (flips `config.toggle_show_resolved()` and refreshes extmarks — used by `:FudeReviewToggleResolved`). The toggle only affects inline comment boxes: `comment_map` always keeps resolved comments, so navigation, the comment viewer, the virtualText style, and the comment browser are unaffected. When boxes are hidden, `ui/extmarks.lua` renders the resolved comments with the virtualText EOL indicator instead. `require("fude.comments")` is the public interface. - - **`comments/data.lua`** — Pure data functions with no state or side effects: `line_from_diff_hunk`, `build_comment_map`, `find_next_comment_line`, `find_prev_comment_line`, `find_comment_by_id`, `get_comment_thread`, `parse_draft_key`, `build_pending_comments_from_review`, `build_review_comment_object`, `build_submit_choices(has_pending_review)` (single/review submit choices, single first; review only while a pending review exists, since GitHub rejects single comments then), `merge_pending_into_comments`, `pending_comments_to_array`, `get_comment_line_range`, `get_reply_target_id`, `get_comments_at`, `get_comment_lines`, `build_comment_entries`, `build_comment_browser_entries`, `merge_draft_entries` (flags existing entries with `has_draft` for matching reply/edit/line/issue drafts and appends `type="draft"` rows for new line/suggest/issue drafts, then re-sorts by `last_ts`), `build_pr_level_comments(issue_comments, reviews)` (issue comments plus non-PENDING, non-empty review bodies converted to issue-comment-shaped objects — `created_at` from `submitted_at`, `review_state`, `is_review_summary = true` — merged oldest first with id as the tie-break; type-checks nested fields since they may be JSON null), `find_thread_key(comment_id, comments, thread_map)` (the id `gh.get_review_threads` indexes the comment's thread under: the root, or the earliest reply keyed in `thread_map` when the root was deleted on GitHub), `find_editable_comment(comments, github_user)` (the user's most recent comment in a thread that is not a review summary — the target of the browser's `e`/`d`, since a review body is edited through another endpoint and cannot be deleted). + - **`comments/data.lua`** — Pure data functions with no state or side effects: `line_from_diff_hunk`, `build_comment_map`, `find_next_comment_line`, `find_prev_comment_line`, `find_comment_by_id`, `get_comment_thread`, `parse_draft_key`, `build_pending_comments_from_review`, `build_review_comment_object`, `build_submit_choices(has_pending_review)` (single/review submit choices, single first; review only while a pending review exists, since GitHub rejects single comments then), `merge_pending_into_comments`, `pending_comments_to_array`, `get_comment_line_range`, `get_multiline_ranges(comments)` (distinct `{ start_line, end_line }` of the multi-line comments in a list; single-line ones skipped), `get_reply_target_id`, `get_comments_at`, `get_comment_lines`, `build_comment_entries`, `build_comment_browser_entries`, `merge_draft_entries` (flags existing entries with `has_draft` for matching reply/edit/line/issue drafts and appends `type="draft"` rows for new line/suggest/issue drafts, then re-sorts by `last_ts`), `build_pr_level_comments(issue_comments, reviews)` (issue comments plus non-PENDING, non-empty review bodies converted to issue-comment-shaped objects — `created_at` from `submitted_at`, `review_state`, `is_review_summary = true` — merged oldest first with id as the tie-break; type-checks nested fields since they may be JSON null), `find_thread_key(comment_id, comments, thread_map)` (the id `gh.get_review_threads` indexes the comment's thread under: the root, or the earliest reply keyed in `thread_map` when the root was deleted on GitHub), `find_editable_comment(comments, github_user)` (the user's most recent comment in a thread that is not a review summary — the target of the browser's `e`/`d`, since a review body is edited through another endpoint and cannot be deleted). - **`comments/sync.lua`** — GitHub API sync/submit operations: `load_comments`, `sync_pending_review`, `submit_as_review`, `create_single_comment` (immediately published line/range comment on HEAD via `gh.create_comment`/`gh.create_comment_range`; fails early while `has_pending_review()`), `toggle_resolved(comment_id, cb(err, resolved))` (fetches the threads fresh — `state.thread_map` is only kept while a pending review exists and `is_resolved` is absent when `resolved.show` is false — then flips the thread via `gh.set_review_thread_resolved` and refreshes silently so the result message stays visible; `state.resolving_thread` refuses a second toggle until the first one's mutation *and* refresh land, since both would read the same state and send the same mutation, and a later refresh finishing first would be overwritten by the older one; both gh callbacks drop a response that arrives after `reset_state()` before any error branch), `has_pending_review` (true when `pending_review_id` is set or `pending_comments` is non-empty — the latter covers the first `sync_pending_review` round-trip before the ID arrives), `reply_to_comment`, `edit_comment`, `delete_comment`. `load_comments` is the main entry point: detects pending review via `GET /pulls/{pr}/reviews`, then fetches both submitted comments (`GET /pulls/{pr}/comments`) and pending review comments (`GET /reviews/{id}/comments`) in a single flow, converting `position` to `line` via `line_from_diff_hunk` and building both `comment_map` and `pending_comments`. Also fetches review threads via GraphQL (skipped when `outdated.show` and `resolved.show` are both false and no pending review exists) and applies `is_outdated` (root comments only, gated by `outdated.show`) and `is_resolved` (per-thread state, propagated to replies via `in_reply_to_id`, gated by `resolved.show`) to comment objects. `comment_map` always includes resolved comments (the resolved-visibility toggle acts only on inline-box rendering in `ui/extmarks.lua`). Internal `fetch_comments` is used by other functions for refreshing after mutations. `fetch_pr_level_comments(pr_number, cb)` requests `issues/{pr}/comments` and `pulls/{pr}/reviews` in parallel (barrier count derived from the operation list) and hands `data.build_pr_level_comments` the results, treating a failed listing as empty so the other is still shown; used by `overview.lua` and `ui/comment_browser.lua`. Uses lazy `require("fude.ui")` to avoid circular dependencies. - **`comments/pickers.lua`** — Entry point for `list_comments`. Delegates to `ui/comment_browser.open()`. - **`ui.lua`** — Facade module re-exporting `ui/format.lua` and `ui/extmarks.lua`. Contains floating window UI: comment input editor, comment viewer (`show_comments_float`: `r` reply, `e` edit, `d` delete, `R` toggle the resolved state of the thread of the comment under the cursor via `comments.toggle_resolve(id)`), PR overview window, reply window, edit window, and review event selector. `require("fude.ui")` is the public interface. `open_comment_input(callback, opts)` accepts `opts.pick_submit_kind(cb)`: called on `` with a non-empty body before the float closes; `cb(kind)` closes it and passes `kind` as the callback action, `cb(nil)` keeps editing. Also exports `close_comment_ui()` (closes the reply/edit window, the comment browser, every viewer float — `show_comments_float` marks its buffer `b:fude_comment_view`, listed by `comment_view_buffers()` — and every single-pane input float in any tab — the UIs that hold or take comments — when the local commit scope is entered), `comment_input_buffers()` (loaded `b:fude_comment` buffers shown in some window of any tabpage, via `win_findbuf` since `bufwinid` only searches the current tab), `has_unsent_comment_input()` (true while any of those, the reply/edit lower pane or the browser's lower pane holds non-blank text; `local/session.checkout_blocker` refuses the commit scope on it so the teardown cannot wipe unsent text), `should_confirm_discard(current_lines, original_lines)` (pure), `prompt_close_decision(allow_draft, on_save, on_discard, prompts?)` (the 3-way save-draft/discard/keep-editing `vim.ui.select` — or Yes/No fallback when `allow_draft` is false — with comment-specific default prompt texts overridable via `prompts.unsaved`/`prompts.discard`; `pr.lua`'s two-pane float uses it directly with PR wording), and `confirm_close_with_draft(buf, original_lines, opts)` (side-effect) used to guard close/cancel actions on comment input buffers: when the buffer is dirty it shows the 3-way prompt via `opts.on_save_draft`/`opts.on_discard`/`opts.on_close`, falling back to the plain Yes/No discard confirmation when `opts.allow_draft` is false. - - **`ui/format.lua`** — Pure format/calculation functions with no state or vim API side effects: `calculate_float_dimensions`, `build_comment_separator(width?)` (the `---` line between comments; `format_comments_for_display`, `format_reply_comments_for_display` and `format_comment_browser_thread` take an optional trailing `separator_width` that callers set to the window width so the line spans it, falling back to `DEFAULT_SEPARATOR_WIDTH` (40)), `format_comments_for_display`, `build_comments_float_title`, `normalize_check`, `format_check_status`, `deduplicate_checks`, `sort_checks`, `build_checks_summary`, `format_review_status`, `build_reviewers_list`, `build_re_request_candidates`, `build_reviewers_summary`, `calculate_overview_layout`, `calculate_comments_height`, `calculate_reply_window_dimensions`, `format_reply_comments_for_display`, `build_overview_left_lines`, `build_overview_right_lines`, `calculate_comment_browser_layout`, `format_comment_browser_list`, `format_comment_browser_thread`, `parse_markdown_line`, `build_highlighted_chunks`, `apply_markdown_highlight_to_line`, `normalize_newlines`. Purity is enforced by `scripts/check_purity.lua` (`make check-purity`). - - **`ui/inline.lua`** — Inline (virt_lines) comment box rendering. `format_comments_for_inline(comments, format_date_fn, opts)` builds bordered comment boxes for use as `nvim_buf_set_extmark` `virt_lines`. The box border label shows `[pending]` for pending comments, else `[resolved thread]` for a resolved thread's head comment only (the oldest one, with no `in_reply_to_id`) — since resolved state is thread-level and propagated to every comment, the label is not repeated on replies. Reads the current window's text width via `vim.api`/`vim.fn` to size boxes, which is why it lives outside the pure `ui/format.lua`. + - **`ui/format.lua`** — Pure format/calculation functions with no state or vim API side effects: `calculate_float_dimensions`, `format_line_range(start_line, end_line)` (`L20-L29`, nil for a single line), `build_range_start_marker(start_line, end_line)` (`↓ comment L20-L29`, "" for a single line), `build_comment_separator(width?)` (the `---` line between comments; `format_comments_for_display`, `format_reply_comments_for_display` and `format_comment_browser_thread` take an optional trailing `separator_width` that callers set to the window width so the line spans it, falling back to `DEFAULT_SEPARATOR_WIDTH` (40)), `format_comments_for_display`, `build_comments_float_title`, `normalize_check`, `format_check_status`, `deduplicate_checks`, `sort_checks`, `build_checks_summary`, `format_review_status`, `build_reviewers_list`, `build_re_request_candidates`, `build_reviewers_summary`, `calculate_overview_layout`, `calculate_comments_height`, `calculate_reply_window_dimensions`, `format_reply_comments_for_display`, `build_overview_left_lines`, `build_overview_right_lines`, `calculate_comment_browser_layout`, `format_comment_browser_list`, `format_comment_browser_thread`, `parse_markdown_line`, `build_highlighted_chunks`, `apply_markdown_highlight_to_line`, `normalize_newlines`. Purity is enforced by `scripts/check_purity.lua` (`make check-purity`). + - **`ui/inline.lua`** — Inline (virt_lines) comment box rendering. `format_comments_for_inline(comments, format_date_fn, opts)` builds bordered comment boxes for use as `nvim_buf_set_extmark` `virt_lines`. The box border label shows `[pending]` for pending comments, else `[resolved thread]` for a resolved thread's head comment only (the oldest one, with no `in_reply_to_id`) — since resolved state is thread-level and propagated to every comment, the label is not repeated on replies. A multi-line thread head is titled with its range (`Comment L20-L29 [pending]`, via `data.get_comment_line_range` + `format.format_line_range`) under the same head-only rule. Reads the current window's text width via `vim.api`/`vim.fn` to size boxes, which is why it lives outside the pure `ui/format.lua`. - **`ui/comment_browser.lua`** — 3-pane floating comment browser for `FudeReviewListComments`. Left pane: comment list (review + PR-level, time-descending; the PR-level entry holds issue comments and submitted review bodies from `sync.fetch_pr_level_comments`, the bodies headed with a `[approved]`-style badge from `format.comment_badges`, and `e`/`d` pick their target via `data.find_editable_comment` so a review body is never edited or deleted), including local drafts via `merge_draft_entries` (existing entries get a `✎draft` marker; new line/suggest/issue drafts appear as `[draft]` rows). Selecting a `type="draft"` row: `line`/`suggest` jumps to the file (``), `issue` focuses the lower pane (prefilled). Right upper: thread display (draft body preview for draft rows). Right lower: reply/edit/new comment input. Supports reply, edit, delete, new PR comment, jump to file, and refresh. `close()` closes it from outside (no-op when closed), used by `ui.close_comment_ui` when the local commit scope is entered. Does not depend on Telescope. - **`ui/sidepanel.lua`** — Toggleable sidebar showing Review Scope and Changed Files. Files default to a flat list (`sidepanel.file_tree = "flat"`); `"tree"` groups files by directory via `ui/sidepanel/tree.lua`. Formatting/navigation helpers: `format_scope_section`, `format_files_section(file_entries, width, format_path_fn, viewed_count, current_path, opts)`, `format_files_section_tree(tree_entries, total_file_count, width, viewed_count, current_path, opts)`, `build_sidepanel_content`, `resolve_entry_at_cursor`, `find_first_file_entry(file_entries, tree_entries)` (first non-directory, non-removed entry in display order; tree entries take precedence), `build_selectable_lines(section_map)` (1-based panel lines that accept `select`, including directories; headers/separators/blanks excluded), `find_adjacent_selectable_line(cursor_line, selectable_lines, direction, count)` (the selectable line `count` steps away — `count` defaults to 1 — resolved by index in one scan, clamped at the edges, no wrap). The Files section header displays viewed/total count (e.g., "Files (Reviewed: 3/10)"). Flat/tree rows share a formatter with fixed current/viewed/status columns, indented fold indicators/icons/names, and right-aligned additions/deletions. Status labels are M/A/D/R/C (sidepanel-only), using foreground highlight groups DiagnosticWarn/DiagnosticOk/DiagnosticError/DiagnosticInfo/DiagnosticInfo respectively (unknown status uses Comment), not the background-oriented Diff* groups. Viewed/unviewed marks use `signs.viewed`/`signs.unviewed` and their `_hl` settings (defaults ✓/○); expanded directories never show review marks; collapsed directories with files show the viewed mark only when all descendants are viewed, otherwise the unviewed mark. `panel.collapsed_dirs` stores directory-path fold state until panel close; `render` passes it to `flatten_tree`, and `` toggles it without moving the cursor. Folder icons switch between open () and closed (); directory arrows (▾/▸) render even without devicons. `render` resolves file marks and optional nvim-web-devicons icons (`sidepanel.icons`, default true) before formatting; the formatters receive these values as arguments (tree formatting also receives `opts.all_file_entries` to keep column widths stable across folds), use `vim.fn` display-cell widths and byte highlight offsets, and truncate names without altering entry paths. Narrow rows reduce indentation/omit icons, and hide stats when complete counts cannot fit beside the fixed columns and a name cell. The currently open file is marked with `▶` (`DiagnosticInfo` highlight), matching the Review Scope marker pattern; cursor follows the active file (or its collapsed ancestor) via `follow_current_file`. Side-effect functions: `open`, `close`, `toggle` (opens when closed, focuses the panel when open but unfocused, closes when the panel is the current window; a panel in another tabpage is reopened in the current one), `refresh`, `follow_current_file`, `reveal_file(path)` (expands all folded ancestors of a next/previous-file target, preserves unrelated folds and source-window focus, then follows the current file), `toggle_file_tree_mode`, `move_to_adjacent_entry(panel, direction, count)` (entry-wise cursor movement for the `next_entry`/`prev_entry` keymaps), `open_file`, `open_first_file()` (opens the Files section's first openable file, including hidden descendants via `files.build_navigation_order`, after a scope switch via the panel's ``; reads `state.sidepanel` at call time — not a captured table — and guards `state.active`, panel window validity, panel focus (staleness check for async completion), and target-window availability (silent no-op), wrapping `open_file` in `pcall` so an `:edit` failure inside a gh callback surfaces as a notify instead of a stack trace; both manual selection and auto-open delegate positioning to `files.open_file`). `close` restores focus to `panel.prev_win` (the window the panel was opened/focused from) only when the panel is the current window, without `noautocmd` so BufEnter re-syncs extmarks/keymaps/preview. Uses `nvim_open_win` with `split` and `win = -1` (top-level split) for sidebar creation, so the panel always opens at the tabpage edge regardless of the focused window. Uses dedicated `fude_sidepanel` namespace for highlights (avoids `refresh_extmarks` clearing them on BufEnter). Auto-refreshes on scope change, reload, BufEnter (to update the current-file marker), and panel width changes. The panel-owned WinResized autocmd compares actual window width with `panel.rendered_width` (rather than matching only the event's first window) and is removed with its augroup on close. Panel-local mappings are configurable with `sidepanel.keymaps`; defaults are `` select/open/fold (on a directory: toggle descendants; on a scope entry: switch scope, then auto-open the first file of the new scope — local mode keys off `set_scope`'s return value, GitHub mode passes an `on_done` callback to `scope.apply_scope`; nothing opens on failure/no-op), `` toggle reviewed/viewed, `j`/`k` move to the next/previous selectable entry (count-aware, no wrap; includes directories; skips headers, separators, and blanks; when two actions resolve to the same key, the first-registered action wins so an explicit user mapping is not overwritten by a newer default), `t` toggle flat/tree, `R` reload, and `q` close. - **Sidepanel quick help** — `sidepanel.keymaps.help` defaults to `?` and is registered last. `setup_keymaps` stores successfully registered `{ action, lhs, desc }` entries in `panel.help_mappings`; key collision checks normalize termcodes (e.g. `` / ``), preserving first-registration priority. The footer below Files advertises only an effective help key. `build_help_lines(mappings, review_mode)` reuses these entries, describing the local scope action separately; it centers keys in a display-cell-width column sized to the longest configured key and aligns descriptions, including the help-only close row. `toggle_help(panel)` opens a read-only float with `float.border` and twice-content dimensions clamped to the screen, tracked by `panel.help_win`; `q` in that float calls `close_help(panel)` independently of the panel's close mapping. Open/close suppress autocmds so the review `BufEnter` handler does not move the panel cursor; no fold state is changed. Direct help closure clears its handle through the panel's WinClosed handler; panel close and review teardown also close the help. - **`ui/sidepanel/tree.lua`** — Pure tree helpers for the sidepanel Files section: `build_tree` (children keep first-appearance order — a directory sits where its first file appears, as in the GitHub PR file tree — with no directories-first or name sort, so flattening path-sorted `changed_files` reproduces the flat order), `collapse_singleton_chains`, `compute_aggregate`, and `flatten_tree(root, viewed_files, collapsed_dirs)` (optional path-keyed fold map; skips hidden descendants while keeping full recursive aggregates). - - **`ui/extmarks.lua`** — Extmark management: `flash_line`, `highlight_comment_lines`, `clear_comment_line_highlight`, `refresh_extmarks`, `refresh_visible_extmarks` (re-renders every window showing a normal buffer, used after a local scope switch), `clear_extmarks`, `clear_all_extmarks`. Uses lazy `require("fude.comments")` to avoid circular dependencies. `refresh_extmarks` also renders the `draft` indicator from `drafts.file_markers(rel_path)` (EOL virt_text, both virtualText and inline modes), and clears the buffer instead of rendering while `local/session.in_commit_scope()` (the buffer is a checked-out past commit, so working-tree positions would land on unrelated lines); it maps the buffer with `local/session.relative_path`, which is relative to the local session's worktree root (falling back to `diff.to_repo_relative` outside local mode) so a `:cd` out of the worktree does not blank every reviewed buffer. A module-local `render_virt_text_indicators(buf, line, comments)` builds the EOL indicators (comment count, pending, resolved `resolved.label` at priority 43 when `util.all_comments_resolved`); it is used both for the virtualText style and, in the inline style, as the fallback for resolved comments whose box is hidden by `:FudeReviewToggleResolved` (so both paths show an identical indicator). + - **`ui/extmarks.lua`** — Extmark management: `flash_line`, `highlight_comment_lines`, `clear_comment_line_highlight`, `refresh_extmarks`, `refresh_visible_extmarks` (re-renders every window showing a normal buffer, used after a local scope switch), `clear_extmarks`, `clear_all_extmarks`. `refresh_extmarks` also colors the line numbers of every multi-line comment range (`number_hl_group` from `comment_range.number_hl`, both styles, clamped to the buffer by the module-local `highlight_range_numbers`), and `update_inline_hint` marks the ranges of the comments on the cursor line through the module-local `mark_cursor_ranges` (line numbers switched to `comment_range.cursor_number_hl` at a higher priority, plus a `build_range_start_marker` EOL text on each range's first line in `start_marker_hl`; both in the hint namespace, removed by `clear_inline_hint`) — a multi-line comment is drawn on its last line, so these show where it starts. No background (`line_hl_group`) or sign is used, so diff colors and gitsigns stay visible. Uses lazy `require("fude.comments")` to avoid circular dependencies. `refresh_extmarks` also renders the `draft` indicator from `drafts.file_markers(rel_path)` (EOL virt_text, both virtualText and inline modes), and clears the buffer instead of rendering while `local/session.in_commit_scope()` (the buffer is a checked-out past commit, so working-tree positions would land on unrelated lines); it maps the buffer with `local/session.relative_path`, which is relative to the local session's worktree root (falling back to `diff.to_repo_relative` outside local mode) so a `:cd` out of the worktree does not blank every reviewed buffer. A module-local `render_virt_text_indicators(buf, line, comments)` builds the EOL indicators (comment count, pending, resolved `resolved.label` at priority 43 when `util.all_comments_resolved`); it is used both for the virtualText style and, in the inline style, as the fallback for resolved comments whose box is hidden by `:FudeReviewToggleResolved` (so both paths show an identical indicator). - **`files.lua`** — Changed files display via Telescope picker, snacks.picker, or quickfix list. Shared file opening: `open_file(filename, entry?)` checks buffer existence before editing, updates an existing diff preview synchronously, then centers only new buffers via `center_first_hunk`/`parse_first_hunk_line` and `resolve_patch`. The parser skips leading context lines and returns the first changed new-file position; deletions use the surviving boundary (the header anchor for a zero-context deletion), clamped to the buffer by the caller. Other existing buffers restore the cursor/scroll/fold settings recorded by `save_view(buf)` in `state.file_views` on BufLeave (cleared on session reset); folds hiding previously visible rows are reopened. Sidepanel selection, scope auto-open, next/previous commands, and picker confirmation all use this path; explicit comment/draft line jumps do not. Quickfix has a list-context-guarded Enter mapping; native quickfix commands keep their usual behavior. Buffers newly registered by the quickfix list have a buffer-local `fude_quickfix_unopened` marker, consumed on the first BufReadPre/BufNewFile even outside fude; buffer-local one-shot autocmds expire on read or wipe. Buffer lookup prefers an exact absolute path before falling back to a resolved alias, and the post-edit guard compares the opened buffer's resolved path directly with the target. All pickers show diff preview and viewed state toggle via ``. Shows GitHub viewed status for each file. Exports `apply_viewed_state(path, new_state, on_done)` as the picker-agnostic state mutator for the viewed state: it invokes gh GraphQL mark/unmark (or `local_sync.set_viewed` in local mode), updates `state.viewed_files`, refreshes the sidepanel (a no-op when closed — the single place every viewed mutation re-renders the panel, so `:FudeReviewViewed`/`Unviewed`, the picker `` toggles and the panel's own `` all keep the mark and the "Reviewed: n/m" header in sync without a reload), and calls `on_done` with the updated display fields; a backend error is notified and `on_done` is not called, and a callback arriving after `reset_state()` is dropped (captured-state identity check). `apply_viewed_toggle(path, on_done)` is the thin flip wrapper over it; the Telescope adapter `toggle_viewed_in_telescope`, the snacks adapter `toggle_viewed_in_snacks(picker, item)` and `ui/sidepanel.toggle_file_viewed` delegate to the toggle, while `init.mark_viewed`/`unmark_viewed` (via the module-local `set_current_file_viewed`, which maps the buffer with `local/session.relative_path`) call `apply_viewed_state` directly since they set rather than flip. `show()` routes to `show_telescope` / `show_snacks` / `show_quickfix` based on `config.opts.file_list_mode`; snacks falls back to quickfix when snacks.nvim is missing. `open_first_file()` opens the first non-removed file in navigation order (leaving the side panel, the preview, a floating window, or a non-empty-`buftype` buffer for a source window first, `pcall`-guarded, then `sidepanel.reveal_file`); used after `:FudeReviewScopeNext`/`Prev`. Also exports `next_file()` / `prev_file()` for jumping between changed files (used by `:FudeReviewNextFile` / `:FudeReviewPrevFile`); both wrap around at the edges and fall back to the first/last entry when the current buffer is not part of the PR. After a successful edit, they call `sidepanel.reveal_file` to expand the target file's collapsed ancestors, guarded by session identity in case edit autocmds stopped/restarted the session. Navigation order follows the sidepanel file list: when the sidepanel is in tree mode (panel open → its current `file_tree_mode`, panel closed → `config.opts.sidepanel.file_tree`) the full tree render order (including collapsed descendants) is used, otherwise the flat `changed_files` order. The pure helpers `find_adjacent_file_index(changed_files, current_path, direction)` computes the target index; `build_navigation_order(changed_files, tree_mode)` returns the navigation-ordered file list, reusing `ui/sidepanel/tree.lua` (`build_tree`/`collapse_singleton_chains`/`flatten_tree`) for tree order; `count_viewed(viewed_files, changed_files)` counts files with VIEWED state (used by sidepanel header). `next_unviewed_file()` / `prev_unviewed_file()` (`:FudeReviewNextUnviewedFile` / `:FudeReviewPrevUnviewedFile`, bound to `]F`/`[F`) walk the same navigation order but stop only on files that are neither marked `VIEWED` in `state.viewed_files` nor `status == "removed"` (a deleted file cannot be opened and is rarely viewed, so it would otherwise be hit on every wrap-around — `ui/sidepanel.find_first_file_entry` skips removed entries for the same reason). The pure helper `find_adjacent_unviewed_index(files, current_path, direction, viewed_files)` reaches the current file last, so it is returned only when it is the sole unviewed target, and returns nil when there is none; `has_unviewed_target(changed_files, viewed_files)` answers the same question without an ordering, and `goto_adjacent` calls it *before* switching away from the side panel so an empty result does not move the user out of the panel just to report it. - **`scope.lua`** — Review scope selection and navigation. Provides a Telescope picker (or `vim.ui.select` fallback) for choosing between full PR scope and individual commit scope, with commit index display (`[1/10]`) and current scope marker (`▶`). Supports next/prev scope navigation (`next_scope`/`prev_scope`, which pass an `on_done` that calls `files.open_first_file()` only while focus is still in the triggering window — or in the rebuilt preview when triggered from the preview), marking commits as reviewed via `` in the Telescope picker (tracked locally in `state.reviewed_commits`), and statusline integration (`statusline()`). On commit scope: checks out the commit, fetches commit-specific changed files, updates gitsigns base to `sha^` (global), and refreshes the diff preview. On full PR scope: restores the original HEAD, re-fetches PR-wide changed files, and computes merge-base (per-buffer gitsigns base is applied via `GitSignsUpdate` autocmd in init.lua). `apply_scope(entry, on_done)` / `apply_full_pr_scope(on_done)` / `apply_commit_scope(sha, on_done)` accept an optional completion callback invoked (after the sidepanel refresh) only when the scope switch succeeded — never on failure or no-op (both functions short-circuit when the requested scope is already active) — used by the sidepanel to auto-open the first file. Their gh callbacks are guarded by a captured `config.state` identity check plus a module-local request generation counter, so a stale response (session stopped/restarted, or a newer switch requested while in flight) touches neither state, UI, nor `on_done`. A module-local `pending_switch` tracks the in-flight target: while a switch is pending the no-op guards compare against it instead of the stale `state.scope`, so re-selecting the pending target dedupes into the in-flight request and selecting anything else supersedes it. `has_pending_commit_checkout()` reports an in-flight commit switch (its checkout already ran) and `cancel_pending_switch()` invalidates the pending switch — both used by `init.stop()` to restore HEAD correctly mid-switch. `refresh_preview()` restores the caller's focused window after rebuilding the preview (`open_preview` otherwise leaves focus on the source window), and is also used by `local/session.set_scope` for its preview rebuild. Exports `apply_reviewed_toggle(sha)` as a picker-agnostic state mutator that toggles `state.reviewed_commits[sha]` and returns the updated display fields `{ is_reviewed, reviewed_icon, reviewed_hl }`; both the Telescope adapter `toggle_reviewed_in_telescope` and the snacks adapter `toggle_reviewed_in_snacks(picker, item)` delegate to it. `select_scope()` routes to `show_telescope` / `show_snacks` / `show_vim_select` based on `config.opts.file_list_mode`; snacks falls back to `vim.ui.select` when snacks.nvim is missing. In local review mode the same three commands work through the local session: `select_scope()` delegates to `local/session.select_scope()`, and `next_scope`/`prev_scope` pick the neighbour of the current entry in `local/session.scope_specs` order with the pure `find_adjacent_local_scope(specs, direction)` (wraps at both ends like the GitHub helpers; first/last entry when none is flagged current; nil for an empty list), then call `local/session.set_scope`, running the same focus-guarded `open_first_file` callback only when it returns true — `set_scope` reports its own refusals (checkout blockers, unresolvable base), so nothing is notified twice; when the neighbour is the current entry itself (only one scope available) an INFO is shown and `set_scope` is not called. - **`stack.lua`** — GitHub stacked PR navigation for review mode (`:FudeReviewStackSwitch`). `select_stack()` (GitHub review only) fetches the stack via `gh.get_pr_stack`, builds entries with the pure `build_stack_entries(prs, current_number)` (open PRs plus the current one, `[i/n] #num title (base ← head)` with `i/n` the position in the whole stack, current marked `▶`), and routes the picker by `config.opts.file_list_mode` like `scope.select_scope` (thin `show_telescope` compact dropdown / `show_snacks` select layout / `show_vim_select`; a missing plugin falls back to `vim.ui.select`). The gh callback and the picker selection are both guarded by a captured `config.state` identity check. `switch_to(entry)` decides the case with the pure `resolve_switch_target(worktrees, current_root, branch)` (paths compared after `vim.fn.resolve`): `current` (branch is HEAD here) is a no-op; `switch` (checked out nowhere) stops the review, runs `gh.checkout_pr`, `checktime`s buffers, and restarts the review — on checkout failure it first `git checkout`s the pre-stop `original_head_ref`/`original_head_sha` again, since gh may switch to an existing local branch before failing its `merge --ff-only`, and leaves the review stopped when that restore fails too (HEAD is on neither PR for sure). While the checkout runs, the module-local flag behind `is_switching()` makes `init.start()` and `local/session.start()` refuse with a WARN (shared `refuse_while_switching()`), so no review reads the branch mid-switch or races the callback's `start()`; `cd` (checked out in another worktree) stops the review, `:cd`s there (`pcall`; failure restarts in place), points windows showing old-worktree buffers at the same file in the new worktree (else `enew`) so the layout survives, wipes those buffers, and restarts the review. Old-worktree buffers are those whose deepest owning worktree root (`find_owning_root` over all worktree roots; `is_path_under` rejects sibling prefixes like `/repo-other`) is the current one, so buffers of a worktree nested inside it (e.g. `.claude/worktrees/x`) are neither wiped nor block the switch. Every refusal (modified buffer under the current worktree, tracked uncommitted changes via `git status --porcelain --untracked-files=no` for `switch`, missing target directory for `cd`) happens before `init.stop()`, and a `stop()` that keeps the session active (HEAD restore failure) aborts the switch. diff --git a/README.md b/README.md index a85a6f5..ad87b1f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/doc/fude.txt b/doc/fude.txt index 1e37f6b..ab8432f 100644 --- a/doc/fude.txt +++ b/doc/fude.txt @@ -720,6 +720,15 @@ File opening position *fude-file-position* |:FudeReviewToggleCommentStyle| are detected, the hint shows those keybindings with `` 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* @@ -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 + }, }) < @@ -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, diff --git a/lua/fude/comments.lua b/lua/fude/comments.lua index 87e2bce..325e131 100644 --- a/lua/fude/comments.lua +++ b/lua/fude/comments.lua @@ -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 diff --git a/lua/fude/comments/data.lua b/lua/fude/comments/data.lua index 00974f8..52abaa7 100644 --- a/lua/fude/comments/data.lua +++ b/lua/fude/comments/data.lua @@ -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 diff --git a/lua/fude/config.lua b/lua/fude/config.lua index 47bbdfc..dedcf0f 100644 --- a/lua/fude/config.lua +++ b/lua/fude/config.lua @@ -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 = "Rc", view_comments = "Rv", diff --git a/lua/fude/ui.lua b/lua/fude/ui.lua index e2a0905..2232212 100644 --- a/lua/fude/ui.lua +++ b/lua/fude/ui.lua @@ -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 diff --git a/lua/fude/ui/extmarks.lua b/lua/fude/ui/extmarks.lua index 2623e8a..4fc8a4f 100644 --- a/lua/fude/ui/extmarks.lua +++ b/lua/fude/ui/extmarks.lua @@ -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. @@ -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 @@ -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 @@ -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 @@ -235,6 +270,8 @@ function M.refresh_extmarks() priority = 44, }) end + + M.rebuild_inline_hint(buf) end --- Clear all extmarks for a specific buffer. @@ -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) @@ -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. @@ -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) 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() +end + --- Setup autocmd for inline hint updates. function M.setup_inline_hint_autocmd() if hint_augroup then diff --git a/lua/fude/ui/format.lua b/lua/fude/ui/format.lua index c3c694a..fc022f5 100644 --- a/lua/fude/ui/format.lua +++ b/lua/fude/ui/format.lua @@ -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 diff --git a/lua/fude/ui/inline.lua b/lua/fude/ui/inline.lua index f6c731f..e13f2be 100644 --- a/lua/fude/ui/inline.lua +++ b/lua/fude/ui/inline.lua @@ -1,5 +1,6 @@ local M = {} +local data = require("fude.comments.data") local format = require("fude.ui.format") local util = require("fude.util") @@ -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 ╭ diff --git a/tests/fude/comments_spec.lua b/tests/fude/comments_spec.lua index 1555864..a6dff64 100644 --- a/tests/fude/comments_spec.lua +++ b/tests/fude/comments_spec.lua @@ -617,6 +617,35 @@ describe("get_comment_line_range", function() end) end) +describe("get_multiline_ranges", function() + it("returns the range of a multi-line comment", function() + local ranges = comments.get_multiline_ranges({ { start_line = 20, line = 29 } }) + assert.are.same({ { start_line = 20, end_line = 29 } }, ranges) + end) + + it("skips single-line comments, including a JSON null start_line", function() + local ranges = comments.get_multiline_ranges({ + { line = 5 }, + { start_line = vim.NIL, line = 6 }, + { start_line = 7, line = 7 }, + }) + assert.are.same({}, ranges) + end) + + it("deduplicates a thread's root and replies that share a range", function() + local ranges = comments.get_multiline_ranges({ + { id = 1, start_line = 3, line = 8 }, + { id = 2, start_line = 3, line = 8, in_reply_to_id = 1 }, + { id = 3, start_line = 5, line = 8 }, + }) + assert.are.same({ { start_line = 3, end_line = 8 }, { start_line = 5, end_line = 8 } }, ranges) + end) + + it("returns an empty list for no comments", function() + assert.are.same({}, comments.get_multiline_ranges({})) + end) +end) + -- Tests for pure data functions added during refactoring describe("data.line_from_diff_hunk", function() diff --git a/tests/fude/extmarks_integration_spec.lua b/tests/fude/extmarks_integration_spec.lua index 5cdc3cb..ce3dc90 100644 --- a/tests/fude/extmarks_integration_spec.lua +++ b/tests/fude/extmarks_integration_spec.lua @@ -391,6 +391,195 @@ describe("extmarks integration", function() end) end) + describe("comment range", function() + local function hl_lines(buf, ns, key) + local lines = {} + for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true })) do + if mark[4][key] then + table.insert(lines, mark[2] + 1) + end + end + table.sort(lines) + return lines + end + + local function setup_range_comment(buf) + vim.api.nvim_set_current_buf(buf) + config.state.active = true + config.state.pending_comments = {} + config.state.comment_map = { + ["test.lua"] = { + [4] = { { id = 1, body = "range", start_line = 2, line = 4 } }, + [6] = { { id = 2, body = "single", line = 6 } }, + }, + } + end + + it("highlights the line numbers of a multi-line comment range", function() + local buf = helpers.create_buf({ "1", "2", "3", "4", "5", "6" }, "test.lua") + setup_range_comment(buf) + + extmarks.refresh_extmarks() + + assert.are.same({ 2, 3, 4 }, hl_lines(buf, config.state.ns_id, "number_hl_group")) + end) + + it("clamps the range to the buffer", function() + local buf = helpers.create_buf({ "1", "2", "3" }, "test.lua") + vim.api.nvim_set_current_buf(buf) + config.state.active = true + config.state.pending_comments = {} + config.state.comment_map = { + ["test.lua"] = { [5] = { { id = 1, body = "past EOF", start_line = 2, line = 5 } } }, + } + + extmarks.refresh_extmarks() + + assert.are.same({ 2, 3 }, hl_lines(buf, config.state.ns_id, "number_hl_group")) + end) + + it("does not highlight line numbers when number_hl is false", function() + config.setup({ comment_range = { number_hl = false } }) + local buf = helpers.create_buf({ "1", "2", "3", "4", "5", "6" }, "test.lua") + setup_range_comment(buf) + + extmarks.refresh_extmarks() + + assert.are.same({}, hl_lines(buf, config.state.ns_id, "number_hl_group")) + end) + + describe("cursor feedback", function() + local hint_ns = vim.api.nvim_create_namespace("fude_inline_hint") + + -- End-of-line texts in the hint namespace, keyed by 1-indexed line + local function eol_texts(buf) + local texts = {} + for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(buf, hint_ns, 0, -1, { details = true })) do + if mark[4].virt_text then + texts[mark[2] + 1] = mark[4].virt_text[1][1] + end + end + return texts + end + + before_each(function() + local diff = require("fude.diff") + helpers.mock(diff, "get_repo_root", function() + return "/repo" + end) + helpers.mock(diff, "make_relative", function() + return "test.lua" + end) + end) + + after_each(function() + -- Resets the cached repo root and clears the hint + extmarks.teardown_inline_hint_autocmd() + end) + + it("emphasizes the range line numbers while the cursor is on the comment line", function() + local buf = helpers.create_buf({ "1", "2", "3", "4", "5", "6" }, "test.lua") + setup_range_comment(buf) + + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + extmarks.update_inline_hint() + + assert.are.same({ 2, 3, 4 }, hl_lines(buf, hint_ns, "number_hl_group")) + for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(buf, hint_ns, 0, -1, { details = true })) do + if mark[4].number_hl_group then + assert.are.equal("DiagnosticWarn", mark[4].number_hl_group) + end + end + end) + + it("puts a marker at the end of the range's first line", function() + local buf = helpers.create_buf({ "1", "2", "3", "4", "5", "6" }, "test.lua") + setup_range_comment(buf) + + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + extmarks.update_inline_hint() + + assert.are.equal(" ↓ comment L2-L4", eol_texts(buf)[2]) + end) + + it("uses no background highlight", function() + local buf = helpers.create_buf({ "1", "2", "3", "4", "5", "6" }, "test.lua") + setup_range_comment(buf) + + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + extmarks.update_inline_hint() + + assert.are.same({}, hl_lines(buf, hint_ns, "line_hl_group")) + end) + + it("removes the feedback when the cursor leaves the comment line", function() + local buf = helpers.create_buf({ "1", "2", "3", "4", "5", "6" }, "test.lua") + setup_range_comment(buf) + + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + extmarks.update_inline_hint() + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + extmarks.update_inline_hint() + + assert.are.same({}, hl_lines(buf, hint_ns, "number_hl_group")) + assert.is_nil(eol_texts(buf)[2]) + end) + + it("shows nothing for a single-line comment beyond the usual hint", function() + local buf = helpers.create_buf({ "1", "2", "3", "4", "5", "6" }, "test.lua") + setup_range_comment(buf) + + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + extmarks.update_inline_hint() + + assert.are.same({}, hl_lines(buf, hint_ns, "number_hl_group")) + local texts = eol_texts(buf) + assert.are.same({ 6 }, vim.tbl_keys(texts)) + end) + + it("rebuilds the feedback when the comments are re-rendered without a cursor move", function() + local buf = helpers.create_buf({ "1", "2", "3", "4", "5", "6" }, "test.lua") + setup_range_comment(buf) + + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + extmarks.update_inline_hint() + -- A reload moved the range start from line 2 to line 3 + config.state.comment_map["test.lua"][4] = { { id = 1, body = "range", start_line = 3, line = 4 } } + extmarks.refresh_extmarks() + + assert.are.same({ 3, 4 }, hl_lines(buf, hint_ns, "number_hl_group")) + local texts = eol_texts(buf) + assert.is_nil(texts[2]) + assert.are.equal(" ↓ comment L3-L4", texts[3]) + end) + + it("removes the feedback when the re-rendered line has no comment left", function() + local buf = helpers.create_buf({ "1", "2", "3", "4", "5", "6" }, "test.lua") + setup_range_comment(buf) + + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + extmarks.update_inline_hint() + config.state.comment_map["test.lua"][4] = nil + extmarks.refresh_extmarks() + + assert.are.same({}, hl_lines(buf, hint_ns, "number_hl_group")) + assert.are.same({}, eol_texts(buf)) + end) + + it("shows neither when both options are false", function() + config.setup({ comment_range = { cursor_number_hl = false, start_marker_hl = false } }) + local buf = helpers.create_buf({ "1", "2", "3", "4", "5", "6" }, "test.lua") + setup_range_comment(buf) + + vim.api.nvim_win_set_cursor(0, { 4, 0 }) + extmarks.update_inline_hint() + + assert.are.same({}, hl_lines(buf, hint_ns, "number_hl_group")) + assert.is_nil(eol_texts(buf)[2]) + end) + end) + end) + describe("flash_line", function() it("creates a temporary highlight extmark", function() local buf = helpers.create_buf({ "line1", "line2", "line3", "line4", "line5" }, "flash_test.lua") diff --git a/tests/fude/ui_spec.lua b/tests/fude/ui_spec.lua index 803cb30..71c707b 100644 --- a/tests/fude/ui_spec.lua +++ b/tests/fude/ui_spec.lua @@ -2262,6 +2262,32 @@ describe("apply_markdown_highlight_to_line", function() end) end) +describe("build_range_start_marker", function() + it("points down to the comment drawn on the last line", function() + assert.are.equal("↓ comment L20-L29", ui.build_range_start_marker(20, 29)) + end) + + it("returns an empty string for a single line", function() + assert.are.equal("", ui.build_range_start_marker(29, 29)) + end) +end) + +describe("format_line_range", function() + it("formats a multi-line range", function() + assert.are.equal("L20-L29", ui.format_line_range(20, 29)) + end) + + it("returns nil for a single line", function() + assert.is_nil(ui.format_line_range(29, 29)) + end) + + it("returns nil for a reversed or non-numeric range", function() + assert.is_nil(ui.format_line_range(29, 20)) + assert.is_nil(ui.format_line_range(nil, 20)) + assert.is_nil(ui.format_line_range(vim.NIL, 20)) + end) +end) + describe("format_comments_for_inline", function() local identity = function(s) return s or "" @@ -2383,6 +2409,55 @@ describe("format_comments_for_inline", function() assert.is_false(find_in_border_at(result, 2, "%[resolved thread%]"), "reply box should not show the label") end) + it("shows the line range of a multi-line comment in the top border", function() + local comments = { + { user = { login = "alice" }, created_at = "2024-01-01", body = "x", start_line = 20, line = 29 }, + } + local result = ui.format_comments_for_inline(comments, identity) + assert.is_true(find_in_top_border(result, "Comment L20%-L29 ─")) + end) + + it("puts the line range before the pending label", function() + local comments = { + { + user = { login = "alice" }, + created_at = "2024-01-01", + body = "x", + start_line = 20, + line = 29, + is_pending = true, + }, + } + local result = ui.format_comments_for_inline(comments, identity) + assert.is_true(find_in_top_border(result, "Comment L20%-L29 %[pending%]")) + end) + + it("does not show a line range for a single-line comment", function() + local comments = { + { user = { login = "alice" }, created_at = "2024-01-01", body = "x", line = 29 }, + } + local result = ui.format_comments_for_inline(comments, identity) + assert.is_false(find_in_top_border(result, "L29")) + end) + + it("shows the line range only on the thread head, not on replies", function() + local comments = { + { id = 1, user = { login = "alice" }, created_at = "2024-01-01", body = "root", start_line = 3, line = 8 }, + { + id = 2, + user = { login = "bob" }, + created_at = "2024-01-02", + body = "reply", + start_line = 3, + line = 8, + in_reply_to_id = 1, + }, + } + local result = ui.format_comments_for_inline(comments, identity) + assert.is_true(find_in_border_at(result, 1, "L3%-L8"), "head box should show the range") + assert.is_false(find_in_border_at(result, 2, "L3%-L8"), "reply box should not show the range") + end) + it("prefers pending label over resolved label in top border", function() local comments = { {