Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<CR>`), `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: `<CR>` select/open, `<Tab>` 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: `<CR>` select/open, `<Tab>` 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 `<Tab>`. 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).
Expand Down Expand Up @@ -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 |

Expand Down
4 changes: 3 additions & 1 deletion doc/fude.txt
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,9 @@ Using lazy.nvim: >lua
When opening a file with `<CR>`, 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*
Expand Down
11 changes: 9 additions & 2 deletions lua/fude/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -312,13 +312,20 @@ 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 entered_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
Expand Down
104 changes: 94 additions & 10 deletions lua/fude/ui/sidepanel.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 = {
Expand All @@ -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" })
Expand Down Expand Up @@ -271,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
Expand All @@ -288,6 +314,28 @@ 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 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
Comment on lines +317 to +329

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3dcab4b で修正 — open() 時に panel.repo_root をキャッシュし、render() でのサブプロセス呼び出しを排除しました。

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
Expand All @@ -297,9 +345,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 =
Expand All @@ -325,6 +375,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).
Expand All @@ -351,6 +413,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
Expand Down Expand Up @@ -401,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

Expand Down
79 changes: 79 additions & 0 deletions tests/fude/sidepanel_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading