From a803f3e3dd7761ccbdfcec6380cd2bd265ecb783 Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:41:27 +0900 Subject: [PATCH 1/4] feat: show current file marker and follow cursor in sidepanel Co-Authored-By: Claude Opus 4.6 --- lua/fude/init.lua | 8 +++- lua/fude/ui/sidepanel.lua | 93 +++++++++++++++++++++++++++++++++++---- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/lua/fude/init.lua b/lua/fude/init.lua index 32fa565..1ae9aa7 100644 --- a/lua/fude/init.lua +++ b/lua/fude/init.lua @@ -316,9 +316,15 @@ function M.start() vim.schedule(function() require("fude.ui").refresh_extmarks() M.setup_buf_keymaps() + local panel = config.state.sidepanel + if panel and panel.win and vim.api.nvim_win_is_valid(panel.win) then + if vim.api.nvim_get_current_buf() ~= panel.buf then + require("fude.ui.sidepanel").follow_current_file() + end + end end) end, - desc = "fude.nvim: Update extmarks and keymaps", + desc = "fude.nvim: Update extmarks, keymaps, and sidepanel marker", }) -- Apply gitsigns base per-buffer after gitsigns attaches diff --git a/lua/fude/ui/sidepanel.lua b/lua/fude/ui/sidepanel.lua index d22f202..b768c82 100644 --- a/lua/fude/ui/sidepanel.lua +++ b/lua/fude/ui/sidepanel.lua @@ -57,10 +57,11 @@ end --- @param width number available width in columns --- @param format_path_fn (fun(s: string): string|nil)|nil formats file path for display (nil = identity) --- @param viewed_count number count of files with VIEWED state +--- @param current_path string|nil repo-relative path of the currently open file --- @return string[] lines --- @return table[] highlights { { line_0idx, col_start, col_end, hl_group } } --- @return number entry_count number of file entries -function M.format_files_section(file_entries, width, format_path_fn, viewed_count) +function M.format_files_section(file_entries, width, format_path_fn, viewed_count, current_path) format_path_fn = format_path_fn or function(p) return p end @@ -71,20 +72,27 @@ function M.format_files_section(file_entries, width, format_path_fn, viewed_coun } for _, entry in ipairs(file_entries) do + local is_current = current_path and entry.path == current_path + local current_icon = is_current and "▶" or " " local viewed = entry.viewed_icon or " " local status = entry.status_icon or "?" local adds = string.format("+%-3d", entry.additions or 0) local dels = string.format("-%-3d", entry.deletions or 0) local raw = format_path_fn(entry.path) local display_name = type(raw) == "string" and raw or entry.path - local text = " " .. viewed .. " " .. status .. " " .. adds .. " " .. dels .. " " .. display_name + local text = current_icon .. " " .. viewed .. " " .. status .. " " .. adds .. " " .. dels .. " " .. display_name local line_idx = #lines table.insert(lines, text) + -- Current file highlight + if is_current then + table.insert(highlights, { line_idx, 0, #current_icon, "DiagnosticInfo" }) + end -- Viewed icon highlight - table.insert(highlights, { line_idx, 1, 1 + #viewed, entry.viewed_hl or "Comment" }) + local viewed_start = #current_icon + 1 + table.insert(highlights, { line_idx, viewed_start, viewed_start + #viewed, entry.viewed_hl or "Comment" }) -- Status icon highlight - local status_start = 1 + #viewed + 1 + local status_start = viewed_start + #viewed + 1 table.insert(highlights, { line_idx, status_start, status_start + #status, entry.status_hl or "DiffChange" }) -- Additions highlight local adds_start = status_start + #status + 1 @@ -102,10 +110,11 @@ end --- @param total_file_count number total number of changed files --- @param width number available width in columns --- @param viewed_count number count of files with VIEWED state +--- @param current_path string|nil repo-relative path of the currently open file --- @return string[] lines --- @return table[] highlights { { line_0idx, col_start, col_end, hl_group } } --- @return number entry_count number of rendered tree entries -function M.format_files_section_tree(tree_entries, total_file_count, width, viewed_count) +function M.format_files_section_tree(tree_entries, total_file_count, width, viewed_count, current_path) viewed_count = viewed_count or 0 local lines = { string.format(" Files (Reviewed: %d/%d)", viewed_count, total_file_count), string.rep("─", width) } local highlights = { @@ -132,14 +141,31 @@ function M.format_files_section_tree(tree_entries, total_file_count, width, view end else local f = entry.file or {} + local is_current = current_path and entry.path == current_path + local current_icon = is_current and "▶" or " " local viewed = f.viewed_icon or " " local status = f.status_icon or "?" local adds = string.format("+%-3d", f.additions or 0) local dels = string.format("-%-3d", f.deletions or 0) - local text = indent .. viewed .. " " .. status .. " " .. adds .. " " .. dels .. " " .. entry.name + local text = indent + .. current_icon + .. " " + .. viewed + .. " " + .. status + .. " " + .. adds + .. " " + .. dels + .. " " + .. entry.name table.insert(lines, text) - local viewed_start = #indent + local ci_start = #indent + if is_current then + table.insert(highlights, { line_idx, ci_start, ci_start + #current_icon, "DiagnosticInfo" }) + end + local viewed_start = ci_start + #current_icon + 1 table.insert(highlights, { line_idx, viewed_start, viewed_start + #viewed, f.viewed_hl or "Comment" }) local status_start = viewed_start + #viewed + 1 table.insert(highlights, { line_idx, status_start, status_start + #status, f.status_hl or "DiffChange" }) @@ -288,6 +314,20 @@ local function render(panel) viewed_count = files_mod.count_viewed(state.viewed_files, state.changed_files or {}) end + -- Determine current file path for marker + local current_path = nil + if repo_root then + local target_win = M.find_target_window(panel.win) + if target_win then + local target_buf = vim.api.nvim_win_get_buf(target_win) + local buf_name = vim.api.nvim_buf_get_name(target_buf) + if buf_name and buf_name ~= "" then + local abs_path = vim.fn.fnamemodify(buf_name, ":p") + current_path = diff_mod.make_relative(abs_path, repo_root) + end + end + end + -- Format sections local scope_lines, scope_hls, scope_count = M.format_scope_section(scope_entries, width) local file_lines, file_hls, file_count @@ -297,9 +337,11 @@ local function render(panel) local tree = tree_mod.build_tree(file_entries) tree_mod.collapse_singleton_chains(tree) tree_entries = tree_mod.flatten_tree(tree, state.viewed_files) - file_lines, file_hls, file_count = M.format_files_section_tree(tree_entries, #file_entries, width, viewed_count) + file_lines, file_hls, file_count = + M.format_files_section_tree(tree_entries, #file_entries, width, viewed_count, current_path) else - file_lines, file_hls, file_count = M.format_files_section(file_entries, width, config.format_path, viewed_count) + file_lines, file_hls, file_count = + M.format_files_section(file_entries, width, config.format_path, viewed_count, current_path) end local lines, highlights, section_map = @@ -325,6 +367,18 @@ local function render(panel) panel.file_entries = file_entries panel.tree_entries = tree_entries panel.section_map = section_map + + -- Compute target cursor line for current file (1-based) + panel.current_file_line = nil + if current_path and section_map then + local entries = tree_entries or file_entries + for i, ent in ipairs(entries) do + if ent.type ~= "directory" and ent.path == current_path then + panel.current_file_line = section_map.files_entry_offset + i + break + end + end + end end --- Refresh the sidepanel content (re-render with current state). @@ -351,6 +405,27 @@ function M.refresh() end end +--- Re-render the sidepanel and move cursor to the currently open file. +--- Used by BufEnter to keep the sidepanel cursor in sync with the active buffer. +function M.follow_current_file() + local panel = config.state.sidepanel + if not panel then + return + end + if not panel.win or not vim.api.nvim_win_is_valid(panel.win) then + config.state.sidepanel = nil + return + end + + render(panel) + + if panel.current_file_line and vim.api.nvim_win_is_valid(panel.win) and vim.api.nvim_buf_is_valid(panel.buf) then + local line_count = vim.api.nvim_buf_line_count(panel.buf) + local target = math.min(panel.current_file_line, line_count) + pcall(vim.api.nvim_win_set_cursor, panel.win, { target, 0 }) + end +end + --- Open the sidepanel. function M.open() local state = config.state From 28ec7a3e49a061121b5086384e0e896151d9cafc Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:41:31 +0900 Subject: [PATCH 2/4] test: add tests for sidepanel current file marker Co-Authored-By: Claude Opus 4.6 --- tests/fude/sidepanel_spec.lua | 79 +++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/fude/sidepanel_spec.lua b/tests/fude/sidepanel_spec.lua index 8c36587..ce22ea0 100644 --- a/tests/fude/sidepanel_spec.lua +++ b/tests/fude/sidepanel_spec.lua @@ -191,6 +191,40 @@ describe("format_files_section", function() local lines = sidepanel.format_files_section(file_entries, 40, nil, 2) assert.truthy(lines[1]:find("Files %(Reviewed: 2/2%)")) end) + + it("shows current file marker for matching path", function() + local lines = sidepanel.format_files_section(file_entries, 80, nil, 1, "lua/fude/scope.lua") + assert.truthy(lines[3]:find("▶")) + end) + + it("does not show marker for non-current files", function() + local lines = sidepanel.format_files_section(file_entries, 80, nil, 1, "lua/fude/scope.lua") + assert.is_falsy(lines[4]:find("▶")) + end) + + it("returns DiagnosticInfo highlight for current file", function() + local _, hls = sidepanel.format_files_section(file_entries, 60, nil, 1, "lua/fude/scope.lua") + local found = false + for _, hl in ipairs(hls) do + if hl[4] == "DiagnosticInfo" then + found = true + break + end + end + assert.is_true(found) + end) + + it("no marker when current_path is nil", function() + local lines = sidepanel.format_files_section(file_entries, 80, nil, 1, nil) + assert.is_falsy(lines[3]:find("▶")) + assert.is_falsy(lines[4]:find("▶")) + end) + + it("no marker when current_path does not match any file", function() + local lines = sidepanel.format_files_section(file_entries, 80, nil, 1, "lua/fude/nonexistent.lua") + assert.is_falsy(lines[3]:find("▶")) + assert.is_falsy(lines[4]:find("▶")) + end) end) describe("format_files_section_tree", function() @@ -293,6 +327,51 @@ describe("format_files_section_tree", function() local lines = sidepanel.format_files_section_tree(entries, #file_entries, 40, 1) assert.are.equal(" Files (Reviewed: 1/2)", lines[1]) end) + + it("shows current file marker for matching file in tree", function() + local file_entries = { + make_file_entry("a/b.md"), + make_file_entry("a/c.md"), + } + local root = tree.build_tree(file_entries) + local entries = tree.flatten_tree(root, {}) + local lines = sidepanel.format_files_section_tree(entries, #file_entries, 40, 0, "a/b.md") + local found_marker = false + local found_no_marker = false + for i = 3, #lines do + if lines[i]:find("b.md") and lines[i]:find("▶") then + found_marker = true + end + if lines[i]:find("c.md") and not lines[i]:find("▶") then + found_no_marker = true + end + end + assert.is_true(found_marker) + assert.is_true(found_no_marker) + end) + + it("does not show marker on directory entries", function() + local file_entries = { + make_file_entry("a/b.md"), + } + local root = tree.build_tree(file_entries) + local entries = tree.flatten_tree(root, {}) + local lines = sidepanel.format_files_section_tree(entries, #file_entries, 40, 0, "a/b.md") + -- Line 3 is the directory "a", should not have ▶ + assert.is_falsy(lines[3]:find("▶")) + end) + + it("no marker when current_path is nil", function() + local file_entries = { + make_file_entry("a/b.md"), + } + local root = tree.build_tree(file_entries) + local entries = tree.flatten_tree(root, {}) + local lines = sidepanel.format_files_section_tree(entries, #file_entries, 40, 0, nil) + for i = 3, #lines do + assert.is_falsy(lines[i]:find("▶")) + end + end) end) describe("build_sidepanel_content", function() From a890f22ca056e3ae7606f18faf52d3d2a7c0c7de Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:41:35 +0900 Subject: [PATCH 3/4] docs: document sidepanel current file marker and cursor follow Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 ++-- doc/fude.txt | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f1a33f8..b943b60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ All plugin code lives under `lua/fude/`. The plugin entry point is `plugin/fude. - **`ui/format.lua`** — Pure format/calculation functions with no state or vim API side effects: `calculate_float_dimensions`, `format_comments_for_display`, `normalize_check`, `format_check_status`, `deduplicate_checks`, `sort_checks`, `build_checks_summary`, `format_review_status`, `build_reviewers_list`, `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`. 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), 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. 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`. Pure functions: `format_scope_section`, `format_files_section(file_entries, width, format_path_fn, viewed_count)`, `format_files_section_tree(tree_entries, total_file_count, width, viewed_count)`, `build_sidepanel_content`, `resolve_entry_at_cursor`. The Files section header displays viewed/total count (e.g., "Files (Reviewed: 3/10)"). Side-effect functions: `open`, `close`, `toggle`, `refresh`, `toggle_file_tree_mode`. Uses `nvim_open_win` with `split` for sidebar creation. Uses dedicated `fude_sidepanel` namespace for highlights (avoids `refresh_extmarks` clearing them on BufEnter). Auto-refreshes on scope change and reload. Keymaps: `` select/open, `` toggle reviewed/viewed, `t` toggle flat/tree, `R` reload, `q` close. + - **`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`. Pure functions: `format_scope_section`, `format_files_section(file_entries, width, format_path_fn, viewed_count, current_path)`, `format_files_section_tree(tree_entries, total_file_count, width, viewed_count, current_path)`, `build_sidepanel_content`, `resolve_entry_at_cursor`. The Files section header displays viewed/total count (e.g., "Files (Reviewed: 3/10)"). The currently open file is marked with `▶` (`DiagnosticInfo` highlight), matching the Review Scope marker pattern; cursor follows the active file via `follow_current_file`. Side-effect functions: `open`, `close`, `toggle`, `refresh`, `follow_current_file`, `toggle_file_tree_mode`. Uses `nvim_open_win` with `split` for sidebar creation. Uses dedicated `fude_sidepanel` namespace for highlights (avoids `refresh_extmarks` clearing them on BufEnter). Auto-refreshes on scope change, reload, and BufEnter (to update the current-file marker). Keymaps: `` select/open, `` toggle reviewed/viewed, `t` toggle flat/tree, `R` reload, `q` close. - **`ui/sidepanel/tree.lua`** — Pure tree helpers for the sidepanel Files section: `build_tree`, `collapse_singleton_chains`, `compute_aggregate`, and `flatten_tree`. - **`ui/extmarks.lua`** — Extmark management: `flash_line`, `highlight_comment_lines`, `clear_comment_line_highlight`, `refresh_extmarks`, `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). - **`files.lua`** — Changed files display via Telescope picker, snacks.picker, or quickfix list. All pickers show diff preview and viewed state toggle via ``. Shows GitHub viewed status for each file. Exports `apply_viewed_toggle(path, on_done)` as a picker-agnostic state mutator that invokes gh GraphQL mark/unmark, updates `state.viewed_files`, and calls `on_done` with the updated display fields; the Telescope adapter `toggle_viewed_in_telescope` and the snacks adapter `toggle_viewed_in_snacks(picker, item)` both delegate to it. `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. 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. The pure helpers `find_adjacent_file_index(changed_files, current_path, direction)` computes the target index; `count_viewed(viewed_files, changed_files)` counts files with VIEWED state (used by sidepanel header). @@ -100,7 +100,7 @@ All plugin code lives under `lua/fude/`. The plugin entry point is `plugin/fude. | `reload_timer` | init | init, config | | `reloading` | init | init | | `gitsigns_reset` | init, scope | init | -| `sidepanel` | ui/sidepanel | ui/sidepanel | +| `sidepanel` | ui/sidepanel | init, ui/sidepanel | | `augroup` | init | init | | `original_diffopt` | init | init | diff --git a/doc/fude.txt b/doc/fude.txt index a426fdf..0c3d438 100644 --- a/doc/fude.txt +++ b/doc/fude.txt @@ -360,7 +360,9 @@ Using lazy.nvim: >lua When opening a file with ``, the cursor moves to a non-panel window before opening the file. The panel stays open. The panel auto-refreshes when the scope changes, data is reloaded, - or viewed/reviewed state is toggled. + viewed/reviewed state is toggled, or the current buffer changes. + The currently open file is marked with `▶` in the Files section, + and the panel cursor follows the active file automatically. Configurable via `sidepanel` options. Requires an active session. :FudeReviewToggleFileTree *:FudeReviewToggleFileTree* From 3dcab4b97568dd4efb9afe102e99729419ec0a0a Mon Sep 17 00:00:00 2001 From: kyu08 <49891479+kyu08@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:13:47 +0900 Subject: [PATCH 4/4] fix: improve sidepanel BufEnter handling (cache repo_root, prefer current win, capture ev.buf) Co-Authored-By: Claude Opus 4.6 --- lua/fude/init.lua | 5 +++-- lua/fude/ui/sidepanel.lua | 13 +++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lua/fude/init.lua b/lua/fude/init.lua index 1ae9aa7..4d532c3 100644 --- a/lua/fude/init.lua +++ b/lua/fude/init.lua @@ -312,13 +312,14 @@ function M.start() vim.api.nvim_create_autocmd("BufEnter", { group = state.augroup, - callback = function() + callback = function(ev) + local entered_buf = ev.buf vim.schedule(function() require("fude.ui").refresh_extmarks() M.setup_buf_keymaps() local panel = config.state.sidepanel if panel and panel.win and vim.api.nvim_win_is_valid(panel.win) then - if vim.api.nvim_get_current_buf() ~= panel.buf then + if entered_buf ~= panel.buf then require("fude.ui.sidepanel").follow_current_file() end end diff --git a/lua/fude/ui/sidepanel.lua b/lua/fude/ui/sidepanel.lua index b768c82..cada12b 100644 --- a/lua/fude/ui/sidepanel.lua +++ b/lua/fude/ui/sidepanel.lua @@ -297,7 +297,7 @@ local function render(panel) ) -- Build file entries (skip if repo root unavailable) - local repo_root = diff_mod.get_repo_root() + local repo_root = panel.repo_root local file_entries = {} local viewed_count = 0 if repo_root then @@ -317,7 +317,15 @@ local function render(panel) -- Determine current file path for marker local current_path = nil if repo_root then - local target_win = M.find_target_window(panel.win) + local current_win = vim.api.nvim_get_current_win() + local target_win = nil + if current_win ~= panel.win and current_win ~= state.preview_win then + local buf = vim.api.nvim_win_get_buf(current_win) + if vim.bo[buf].buftype == "" then + target_win = current_win + end + end + target_win = target_win or M.find_target_window(panel.win) if target_win then local target_buf = vim.api.nvim_win_get_buf(target_win) local buf_name = vim.api.nvim_buf_get_name(target_buf) @@ -476,6 +484,7 @@ function M.open() section_map = nil, augroup = nil, file_tree_mode = sp_opts.file_tree or "flat", + repo_root = get_diff().get_repo_root(), } state.sidepanel = panel