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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ 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. Pure functions: `format_scope_section`, `format_files_section`, `build_sidepanel_content`, `resolve_entry_at_cursor`. Side-effect functions: `open`, `close`, `toggle`, `refresh`. 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, `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`, `format_files_section_tree`, `build_sidepanel_content`, `resolve_entry_at_cursor`. 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/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 helper `find_adjacent_file_index(changed_files, current_path, direction)` computes the target index.
- **`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`), marking commits as reviewed via `<Tab>` 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). 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.
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ PR code review inside Neovim. Review GitHub pull requests without leaving your e
"FudeReviewFiles", "FudeReviewNextFile", "FudeReviewPrevFile",
"FudeReviewScope", "FudeReviewScopeNext", "FudeReviewScopePrev",
"FudeReviewOverview", "FudeReviewSubmit", "FudeOpenPRURL", "FudeCopyPRURL",
"FudeReviewViewed", "FudeReviewUnviewed", "FudeReviewReload", "FudeCreatePR",
"FudeReviewViewed", "FudeReviewUnviewed", "FudeReviewReload", "FudeReviewPanel",
"FudeReviewToggleFileTree", "FudeCreatePR",
},
keys = {
{ "<leader>et", "<cmd>FudeReviewToggle<cr>", desc = "Review: Toggle" },
Expand Down Expand Up @@ -123,6 +124,8 @@ PR code review inside Neovim. Review GitHub pull requests without leaving your e
| `:FudeReviewReload` | Reload review data from GitHub |
| `:FudeReviewToggleCommentStyle` | Toggle comment display style (virtualText/inline) |
| `:FudeReviewToggleGitsigns` | Toggle gitsigns between PR base and HEAD |
| `:FudeReviewPanel` | Toggle review side panel |
| `:FudeReviewToggleFileTree` | Toggle side panel files between flat list and tree |
| `:FudeCreatePR` | Create draft PR from template |

## Configuration
Expand Down Expand Up @@ -203,6 +206,12 @@ require("fude").setup({
label = "[outdated]", -- Label string for outdated comments
hl_group = "Comment", -- Highlight group for outdated label in comment browser
},
-- Side panel options
sidepanel = {
width = 40, -- Panel width in columns
position = "left", -- "left" or "right"
file_tree = "flat", -- "flat" or "tree"
},
-- Callback after review start completes (all data fetched)
-- Receives: { pr_number, base_ref, head_ref, pr_url }
on_review_start = nil,
Expand Down
14 changes: 14 additions & 0 deletions doc/fude.txt
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ Using lazy.nvim: >lua
Keymaps (panel):~
`<CR>` Select scope entry or open file
`<Tab>` Toggle reviewed (scope) / viewed (file)
`t` Toggle files between flat list and tree
`R` Reload review data from GitHub
`q` Close panel

Expand All @@ -361,6 +362,12 @@ Using lazy.nvim: >lua
or viewed/reviewed state is toggled.
Configurable via `sidepanel` options. Requires an active session.

:FudeReviewToggleFileTree *:FudeReviewToggleFileTree*
Toggle the open side panel's Files section between flat list and
directory tree. This changes only the current panel session; the
default is controlled by `sidepanel.file_tree`.
Requires the side panel to be open.

:FudeReviewToggleGitsigns *:FudeReviewToggleGitsigns*
Toggle gitsigns base between PR base and HEAD.
By default during review mode, gitsigns uses the PR base (or commit
Expand Down Expand Up @@ -494,6 +501,7 @@ Default configuration: >lua
sidepanel = {
width = 40, -- Panel width in columns (minimum 20)
position = "left", -- "left" or "right"
file_tree = "flat", -- "flat" or "tree"
},
-- Outdated comment display options
outdated = {
Expand Down Expand Up @@ -660,6 +668,12 @@ Options:
Side of the editor to open the panel: "left" or "right"
(default: "left").

`sidepanel.file_tree`
Files section layout for the side panel: "flat" or "tree"
(default: "flat"). The tree layout groups changed files
by directory and can be toggled for the current panel with
|:FudeReviewToggleFileTree| or `t` in the panel.

`outdated.show` Show outdated comments (default: true).
Outdated comments are those whose position in the code
can no longer be tracked due to subsequent changes.
Expand Down
1 change: 1 addition & 0 deletions lua/fude/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ M.defaults = {
sidepanel = {
width = 40, -- Panel width in columns
position = "left", -- "left" or "right"
file_tree = "flat", -- "flat" or "tree"
},
-- Callback invoked after review start completes (all data fetched).
-- Receives a table: { pr_number, base_ref, head_ref, pr_url }
Expand Down
103 changes: 99 additions & 4 deletions lua/fude/ui/sidepanel.lua
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,60 @@ function M.format_files_section(file_entries, width, format_path_fn)
return lines, highlights, #file_entries
end

--- Format the files section lines as a directory tree.
--- @param tree_entries table[] entries from ui.sidepanel.tree.flatten_tree
--- @param total_file_count number total number of changed files
--- @param width number available width in columns
--- @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)
local lines = { string.format(" Files (%d)", total_file_count), string.rep("─", width) }
local highlights = {
{ 0, 0, -1, "Title" },
}

for _, entry in ipairs(tree_entries) do
local indent = string.rep(" ", entry.depth)
local line_idx = #lines

if entry.type == "directory" then
local viewed_all = entry.total_files > 0 and entry.viewed_files == entry.total_files
local viewed_sign = (config.opts.signs and config.opts.signs.viewed) or "✓"
local viewed_marker = viewed_all and (" " .. viewed_sign) or ""
local text = indent .. entry.name .. viewed_marker
table.insert(lines, text)

local pos = #indent
table.insert(highlights, { line_idx, pos, pos + #entry.name, "Directory" })
if viewed_all then
local viewed_hl = (config.opts.signs and config.opts.signs.viewed_hl) or "DiagnosticOk"
local marker_start = pos + #entry.name + 1
table.insert(highlights, { line_idx, marker_start, marker_start + #viewed_sign, viewed_hl })
end
else
local f = entry.file 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
table.insert(lines, text)

local viewed_start = #indent
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" })
local adds_start = status_start + #status + 1
table.insert(highlights, { line_idx, adds_start, adds_start + #adds, "DiffAdd" })
local dels_start = adds_start + #adds + 1
table.insert(highlights, { line_idx, dels_start, dels_start + #dels, "DiffDelete" })
end
end

return lines, highlights, #tree_entries
end

--- Build the full sidepanel buffer content from scope and files sections.
--- @param scope_lines string[]
--- @param scope_hls table[]
Expand Down Expand Up @@ -230,7 +284,18 @@ local function render(panel)

-- Format sections
local scope_lines, scope_hls, scope_count = M.format_scope_section(scope_entries, width)
local file_lines, file_hls, file_count = M.format_files_section(file_entries, width, config.format_path)
local file_lines, file_hls, file_count
local tree_entries
if (panel.file_tree_mode or sp_opts.file_tree) == "tree" then
local tree_mod = require("fude.ui.sidepanel.tree")
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)
else
file_lines, file_hls, file_count = M.format_files_section(file_entries, width, config.format_path)
end

local lines, highlights, section_map =
M.build_sidepanel_content(scope_lines, scope_hls, scope_count, file_lines, file_hls, file_count)

Expand All @@ -252,6 +317,7 @@ local function render(panel)
-- Store entries and map for keymap handlers
panel.scope_entries = scope_entries
panel.file_entries = file_entries
panel.tree_entries = tree_entries
panel.section_map = section_map
end

Expand Down Expand Up @@ -325,8 +391,10 @@ function M.open()
buf = buf,
scope_entries = {},
file_entries = {},
tree_entries = nil,
section_map = nil,
augroup = nil,
file_tree_mode = sp_opts.file_tree or "flat",
}
state.sidepanel = panel

Expand Down Expand Up @@ -417,6 +485,10 @@ function M.setup_keymaps(panel)
M.toggle_file_viewed(panel, entry_info)
end
end, { buffer = buf, desc = "Toggle reviewed/viewed" })

vim.keymap.set("n", "t", function()
M.toggle_file_tree_mode(panel)
end, { buffer = buf, desc = "Toggle tree/flat file list" })
end

--- Get the entry under the cursor.
Expand All @@ -442,9 +514,19 @@ function M.get_current_entry(panel)
return { type = "scope", index = result.index, entry = entry }
end
elseif result.type == "file" then
local entry = panel.file_entries[result.index]
if entry then
return { type = "file", index = result.index, entry = entry }
if panel.tree_entries then
local tree_entry = panel.tree_entries[result.index]
if tree_entry then
if tree_entry.type == "directory" then
return { type = "directory", index = result.index, entry = tree_entry }
end
return { type = "file", index = result.index, entry = tree_entry.file, tree_entry = tree_entry }
end
else
local entry = panel.file_entries[result.index]
if entry then
return { type = "file", index = result.index, entry = entry }
end
end
end

Expand Down Expand Up @@ -496,6 +578,19 @@ function M.toggle_scope_reviewed(_panel, entry_info)
M.refresh()
end

--- Toggle the panel's file display mode for the current panel session.
--- @param panel table|nil sidepanel state (defaults to active panel)
function M.toggle_file_tree_mode(panel)
panel = panel or config.state.sidepanel
if not panel then
vim.notify("fude.nvim: Side panel is not open", vim.log.levels.WARN)
return
end
panel.file_tree_mode = panel.file_tree_mode == "tree" and "flat" or "tree"
vim.notify("fude.nvim: File list mode: " .. panel.file_tree_mode, vim.log.levels.INFO)
M.refresh()
end

--- Toggle viewed state for a file entry.
--- @param panel table sidepanel state
--- @param entry_info table { type, index, entry }
Expand Down
Loading
Loading