diff --git a/CLAUDE.md b/CLAUDE.md index d5f6f59..6f0ecdb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 (``), `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: `` select/open, `` 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: `` 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 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 `` 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. diff --git a/README.md b/README.md index 7cb6cb1..4f1e40b 100644 --- a/README.md +++ b/README.md @@ -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 = { { "et", "FudeReviewToggle", desc = "Review: Toggle" }, @@ -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 @@ -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, diff --git a/doc/fude.txt b/doc/fude.txt index f30d1f7..4969a83 100644 --- a/doc/fude.txt +++ b/doc/fude.txt @@ -352,6 +352,7 @@ Using lazy.nvim: >lua Keymaps (panel):~ `` Select scope entry or open file `` Toggle reviewed (scope) / viewed (file) + `t` Toggle files between flat list and tree `R` Reload review data from GitHub `q` Close panel @@ -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 @@ -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 = { @@ -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. diff --git a/lua/fude/config.lua b/lua/fude/config.lua index 73ada94..e6661ba 100644 --- a/lua/fude/config.lua +++ b/lua/fude/config.lua @@ -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 } diff --git a/lua/fude/ui/sidepanel.lua b/lua/fude/ui/sidepanel.lua index 64902a6..3e561c6 100644 --- a/lua/fude/ui/sidepanel.lua +++ b/lua/fude/ui/sidepanel.lua @@ -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[] @@ -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) @@ -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 @@ -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 @@ -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. @@ -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 @@ -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 } diff --git a/lua/fude/ui/sidepanel/tree.lua b/lua/fude/ui/sidepanel/tree.lua new file mode 100644 index 0000000..be6fe74 --- /dev/null +++ b/lua/fude/ui/sidepanel/tree.lua @@ -0,0 +1,162 @@ +local M = {} + +--- Build a directory tree from a list of file entries. +--- @param file_entries table[] entries from files.build_file_entries (must have .path) +--- @return table root tree node { name, path, type = "directory", children } +function M.build_tree(file_entries) + local root = { name = "", path = "", type = "directory", _dirs = {}, _files = {} } + + for _, file in ipairs(file_entries or {}) do + local parts = vim.split(file.path, "/", { plain = true }) + local current = root + for i = 1, #parts - 1 do + local part = parts[i] + local child_path = table.concat(parts, "/", 1, i) + if not current._dirs[part] then + current._dirs[part] = { + name = part, + path = child_path, + type = "directory", + _dirs = {}, + _files = {}, + } + end + current = current._dirs[part] + end + table.insert(current._files, { + name = parts[#parts], + path = file.path, + type = "file", + file = file, + }) + end + + local function finalize(node) + local children = {} + local dir_names = {} + for name, _ in pairs(node._dirs) do + table.insert(dir_names, name) + end + table.sort(dir_names) + for _, name in ipairs(dir_names) do + local child = node._dirs[name] + finalize(child) + table.insert(children, child) + end + table.sort(node._files, function(a, b) + return a.name < b.name + end) + for _, file in ipairs(node._files) do + table.insert(children, file) + end + node.children = children + node._dirs = nil + node._files = nil + end + finalize(root) + + return root +end + +--- Collapse chains of single-child directories into one node. +--- @param node table tree root or subtree from build_tree +--- @return table the same node, post-merge +function M.collapse_singleton_chains(node) + for _, child in ipairs(node.children or {}) do + if child.type == "directory" then + M.collapse_singleton_chains(child) + end + end + + while + node.type == "directory" + and node.path ~= "" + and node.children + and #node.children == 1 + and node.children[1].type == "directory" + do + local only_child = node.children[1] + node.name = node.name .. "/" .. only_child.name + node.path = only_child.path + node.children = only_child.children + end + + return node +end + +--- Compute aggregate stats for a node. +--- @param node table tree node +--- @param viewed_files table|nil { [path] = "VIEWED" | ... } +--- @param cache table|nil memoized aggregate values by node +--- @return table { additions, deletions, total_files, viewed_files } +function M.compute_aggregate(node, viewed_files, cache) + viewed_files = viewed_files or {} + cache = cache or {} + if cache[node] then + return cache[node] + end + + if node.type == "file" then + local f = node.file or {} + local agg = { + additions = f.additions or 0, + deletions = f.deletions or 0, + total_files = 1, + viewed_files = viewed_files[node.path] == "VIEWED" and 1 or 0, + } + cache[node] = agg + return agg + end + + local agg = { additions = 0, deletions = 0, total_files = 0, viewed_files = 0 } + for _, child in ipairs(node.children or {}) do + local child_agg = M.compute_aggregate(child, viewed_files, cache) + agg.additions = agg.additions + child_agg.additions + agg.deletions = agg.deletions + child_agg.deletions + agg.total_files = agg.total_files + child_agg.total_files + agg.viewed_files = agg.viewed_files + child_agg.viewed_files + end + cache[node] = agg + return agg +end + +--- Flatten the tree into render-order entries. +--- @param root table from build_tree +--- @param viewed_files table|nil for aggregate viewed counts +--- @return table[] entries +function M.flatten_tree(root, viewed_files) + local entries = {} + local aggregate_cache = {} + + local function visit(node, depth) + for _, child in ipairs(node.children or {}) do + if child.type == "directory" then + local agg = M.compute_aggregate(child, viewed_files, aggregate_cache) + table.insert(entries, { + type = "directory", + path = child.path, + name = child.name, + depth = depth, + additions = agg.additions, + deletions = agg.deletions, + total_files = agg.total_files, + viewed_files = agg.viewed_files, + }) + visit(child, depth + 1) + else + table.insert(entries, { + type = "file", + path = child.path, + name = child.name, + depth = depth, + file = child.file, + }) + end + end + end + + visit(root, 0) + return entries +end + +return M diff --git a/plugin/fude.lua b/plugin/fude.lua index 30df788..498a310 100644 --- a/plugin/fude.lua +++ b/plugin/fude.lua @@ -168,3 +168,7 @@ end, { desc = "Toggle gitsigns between PR base and HEAD" }) vim.api.nvim_create_user_command("FudeReviewPanel", function() require("fude.ui.sidepanel").toggle() end, { desc = "Toggle review side panel" }) + +vim.api.nvim_create_user_command("FudeReviewToggleFileTree", function() + require("fude.ui.sidepanel").toggle_file_tree_mode() +end, { desc = "Toggle review side panel file list between flat and tree" }) diff --git a/tests/fude/sidepanel_integration_spec.lua b/tests/fude/sidepanel_integration_spec.lua index c4c4a9f..05fff26 100644 --- a/tests/fude/sidepanel_integration_spec.lua +++ b/tests/fude/sidepanel_integration_spec.lua @@ -187,4 +187,41 @@ describe("sidepanel integration", function() -- One changed file assert.are.equal(1, #panel.file_entries) end) + + it("uses flat files by default", function() + config.state.changed_files = { + { path = "a/b.lua", status = "modified", additions = 1, deletions = 0 }, + } + sidepanel.open() + local panel = config.state.sidepanel + local lines = vim.api.nvim_buf_get_lines(panel.buf, 0, -1, false) + + assert.is_nil(panel.tree_entries) + assert.are.equal("flat", panel.file_tree_mode) + assert.is_false(vim.tbl_contains(lines, "a")) + end) + + it("uses tree files when configured", function() + config.setup({ sidepanel = { file_tree = "tree" } }) + config.state.active = true + config.state.pr_number = 1 + config.state.base_ref = "main" + config.state.head_ref = "feat/test" + config.state.changed_files = { + { path = "a/b.lua", status = "modified", additions = 1, deletions = 0 }, + } + config.state.pr_commits = {} + config.state.viewed_files = {} + config.state.reviewed_commits = {} + config.state.comments = {} + config.state.pending_comments = {} + + sidepanel.open() + local panel = config.state.sidepanel + local lines = vim.api.nvim_buf_get_lines(panel.buf, 0, -1, false) + + assert.is_not_nil(panel.tree_entries) + assert.are.equal("tree", panel.file_tree_mode) + assert.is_true(vim.tbl_contains(lines, "a")) + end) end) diff --git a/tests/fude/sidepanel_spec.lua b/tests/fude/sidepanel_spec.lua index a10f595..6791f2a 100644 --- a/tests/fude/sidepanel_spec.lua +++ b/tests/fude/sidepanel_spec.lua @@ -1,4 +1,5 @@ local sidepanel = require("fude.ui.sidepanel") +local config = require("fude.config") describe("format_scope_section", function() local scope_entries = { @@ -187,6 +188,97 @@ describe("format_files_section", function() end) end) +describe("format_files_section_tree", function() + local tree = require("fude.ui.sidepanel.tree") + + after_each(function() + config.setup({}) + end) + + local function make_file_entry(path, opts) + opts = opts or {} + return { + path = path, + additions = opts.additions or 0, + deletions = opts.deletions or 0, + status_icon = opts.status_icon or "~", + status_hl = "DiffChange", + viewed_icon = opts.viewed_icon or " ", + viewed_hl = "Comment", + } + end + + it("renders header with total file count", function() + local file_entries = { + make_file_entry("a/b.md"), + make_file_entry("a/c.md"), + make_file_entry("d.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) + assert.are.equal(" Files (3)", lines[1]) + end) + + it("renders directories as indented labels", function() + local file_entries = { make_file_entry("a/b/c.md") } + local root = tree.build_tree(file_entries) + local entries = tree.flatten_tree(root, {}) + local lines = sidepanel.format_files_section_tree(entries, 1, 40) + assert.are.equal("a", lines[3]) + assert.are.equal(" b", lines[4]) + assert.is_truthy(lines[5]:find(" ")) + assert.truthy(lines[5]:find("c.md")) + end) + + it("does not render directory aggregate totals", function() + local file_entries = { + make_file_entry("a/b.md", { additions = 7, deletions = 2 }), + make_file_entry("a/c.md", { additions = 3, deletions = 1 }), + } + local root = tree.build_tree(file_entries) + local entries = tree.flatten_tree(root, {}) + local lines = sidepanel.format_files_section_tree(entries, #file_entries, 40) + + assert.are.equal("a", lines[3]) + end) + + it("uses configured viewed sign for fully viewed directories", function() + config.setup({ signs = { viewed = "●" } }) + 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, { ["a/b.md"] = "VIEWED", ["a/c.md"] = "VIEWED" }) + local lines = sidepanel.format_files_section_tree(entries, #file_entries, 40) + + assert.are.equal("a ●", lines[3]) + end) + + it("keeps flat row diff columns on file entries", function() + local file_entries = { make_file_entry("a/foo.md", { additions = 7, deletions = 2 }) } + local root = tree.build_tree(file_entries) + local entries = tree.flatten_tree(root, {}) + local lines = sidepanel.format_files_section_tree(entries, 1, 40) + assert.truthy(lines[4]:find("~")) + assert.truthy(lines[4]:find("%+7")) + assert.truthy(lines[4]:find("%-2")) + assert.truthy(lines[4]:find("foo.md")) + end) + + it("returns rendered tree-entry count", function() + local file_entries = { + make_file_entry("a/b.md"), + make_file_entry("c.md"), + } + local root = tree.build_tree(file_entries) + local entries = tree.flatten_tree(root, {}) + local _, _, count = sidepanel.format_files_section_tree(entries, #file_entries, 40) + assert.are.equal(3, count) + end) +end) + describe("build_sidepanel_content", function() it("combines scope and file sections with blank separator", function() local scope_lines = { "Header S", "---", "Entry S1" } diff --git a/tests/fude/sidepanel_tree_spec.lua b/tests/fude/sidepanel_tree_spec.lua new file mode 100644 index 0000000..e0fac1f --- /dev/null +++ b/tests/fude/sidepanel_tree_spec.lua @@ -0,0 +1,126 @@ +local tree = require("fude.ui.sidepanel.tree") + +local function make_file(path, opts) + opts = opts or {} + return { + path = path, + filename = "/repo/" .. path, + additions = opts.additions or 0, + deletions = opts.deletions or 0, + status_icon = opts.status_icon or "~", + viewed_icon = opts.viewed_icon or " ", + } +end + +describe("build_tree", function() + it("returns root with empty children for empty input", function() + local root = tree.build_tree({}) + assert.are.equal("directory", root.type) + assert.are.same({}, root.children) + end) + + it("orders directories before files alphabetically", function() + local root = tree.build_tree({ + make_file("zoo.md"), + make_file("apple.md"), + make_file("dir2/x.md"), + make_file("dir1/y.md"), + }) + assert.are.equal("dir1", root.children[1].name) + assert.are.equal("dir2", root.children[2].name) + assert.are.equal("apple.md", root.children[3].name) + assert.are.equal("zoo.md", root.children[4].name) + end) + + it("groups files under shared parent directories", function() + local root = tree.build_tree({ + make_file("a/b/foo.lua"), + make_file("a/b/bar.lua"), + make_file("a/c.lua"), + }) + local a = root.children[1] + assert.are.equal("a", a.name) + assert.are.equal(2, #a.children) + assert.are.equal("b", a.children[1].name) + assert.are.equal("c.lua", a.children[2].name) + assert.are.equal("bar.lua", a.children[1].children[1].name) + assert.are.equal("foo.lua", a.children[1].children[2].name) + end) +end) + +describe("collapse_singleton_chains", function() + it("merges a chain of single-child directories", function() + local root = tree.build_tree({ make_file("a/b/c/d/foo.md") }) + tree.collapse_singleton_chains(root) + assert.are.equal(1, #root.children) + assert.are.equal("a/b/c/d", root.children[1].name) + assert.are.equal("foo.md", root.children[1].children[1].name) + end) + + it("does not merge a directory whose only child is a file", function() + local root = tree.build_tree({ make_file("a/foo.md") }) + tree.collapse_singleton_chains(root) + assert.are.equal("a", root.children[1].name) + end) + + it("merges within branches independently", function() + local root = tree.build_tree({ + make_file("a/b/c/x.md"), + make_file("a/d/e/y.md"), + }) + tree.collapse_singleton_chains(root) + local a = root.children[1] + assert.are.equal("a", a.name) + assert.are.equal("b/c", a.children[1].name) + assert.are.equal("d/e", a.children[2].name) + end) +end) + +describe("compute_aggregate", function() + it("sums counts and viewed files recursively", function() + local root = tree.build_tree({ + make_file("a/x.md", { additions = 1, deletions = 1 }), + make_file("a/b/y.md", { additions = 2, deletions = 0 }), + make_file("a/b/z.md", { additions = 4, deletions = 5 }), + }) + local agg = tree.compute_aggregate(root.children[1], { ["a/x.md"] = "VIEWED" }) + assert.are.equal(7, agg.additions) + assert.are.equal(6, agg.deletions) + assert.are.equal(3, agg.total_files) + assert.are.equal(1, agg.viewed_files) + end) + + it("reuses cached aggregate values", function() + local root = tree.build_tree({ + make_file("a/x.md", { additions = 1 }), + make_file("a/y.md", { additions = 2 }), + }) + local cache = {} + local first = tree.compute_aggregate(root.children[1], {}, cache) + local second = tree.compute_aggregate(root.children[1], {}, cache) + + assert.are.equal(3, first.additions) + assert.are.same(first, second) + end) +end) + +describe("flatten_tree", function() + it("emits directories and files in render order with depth", function() + local root = tree.build_tree({ + make_file("a/b.md", { additions = 5 }), + make_file("c.md"), + }) + local entries = tree.flatten_tree(root, {}) + assert.are.equal(3, #entries) + assert.are.equal("directory", entries[1].type) + assert.are.equal("a", entries[1].name) + assert.are.equal(0, entries[1].depth) + assert.are.equal("file", entries[2].type) + assert.are.equal("b.md", entries[2].name) + assert.are.equal(1, entries[2].depth) + assert.are.equal("file", entries[3].type) + assert.are.equal("c.md", entries[3].name) + assert.are.equal(5, entries[1].additions) + assert.are.equal(1, entries[1].total_files) + end) +end)