From 11c65b1c1b1ba52f3739ca9e4f30432fef3248c1 Mon Sep 17 00:00:00 2001 From: orandvlp <143298175+orandvlp@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:48:34 +0100 Subject: [PATCH 1/3] feat: per-section +/- totals and spacing on panel headers Pin each section's aggregate additions/deletions on its header row (Staged/Unstaged/Untracked), in the same content column as the per-file counts and the --stat help-line total, so a section's weight reads at a glance. Totals are summed fold-independently, matching the existing global --stat sums. Separate consecutive sections with a blank line so headers don't butt up against the previous section's files; the first section is skipped since it already follows the top header's blank. The horizontal (top/bottom) panel width-fit now accounts for a header's aggregate width so a wide "+N -M" can't clip off the edge. --- lua/differ/panel/init.lua | 31 ++++++++++++++++++++++++ lua/differ/panel/render.lua | 20 ++++++++++++++- test/nvim/panel_spec.lua | 8 +++--- test/unit/panel_render_spec.lua | 43 +++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index 5a8b675..98c00fa 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -243,18 +243,25 @@ function Panel:render() local add_total, del_total = 0, 0 for bi, sec in ipairs(self.sections) do local root, strip = self:_section_root(sec) + -- per-section +/- totals, summed like the global ones (fold-independent), + -- pinned on the section header as a column total above its files' counts + local sec_add, sec_del = 0, 0 for _, row in ipairs(tree.rows(root, "tree", {})) do -- fully expanded if row.kind == "file" then total = total + 1 abs_of[row.entry] = total add_total = add_total + (row.entry.additions or 0) del_total = del_total + (row.entry.deletions or 0) + sec_add = sec_add + (row.entry.additions or 0) + sec_del = sec_del + (row.entry.deletions or 0) end end blocks[#blocks + 1] = { title = sec.title, prefix = strip ~= "" and strip or nil, count = #sec.entries, + add = sec_add, + del = sec_del, -- fold state is namespaced per section: the same dir path can list under -- two sections at once (e.g. an untracked src/ and an unstaged src/), so a -- shared key would collapse both rows from one toggle @@ -288,6 +295,13 @@ function Panel:render() and #("+" .. e.additions) + #("-" .. e.deletions) + 2 or 0 needed = math.max(needed, #out.lines[i] + reserve) + elseif m.kind == "header" then + -- the section aggregate pins to the same column, so its width counts + -- too: a wide "+N -M" on a short header must not clip off the edge + local reserve = ((m.add_total or 0) > 0 or (m.del_total or 0) > 0) + and #("+" .. m.add_total) + #("-" .. m.del_total) + 2 + or 0 + needed = math.max(needed, #out.lines[i] + reserve) end end width = math.min(live, math.max(needed, self.width)) @@ -375,6 +389,23 @@ function Panel:_highlight() { end_col = m.prefix_end, hl_group = "differPanelContext" } ) end + -- section +/- aggregate, pinned to the same content column as the per-file + -- counts so it stacks directly above them as a column total (virt_text, not + -- line text, same as the --stat help line and the file rows) + if (m.add_total or 0) > 0 or (m.del_total or 0) > 0 then + local add = ("+%d"):format(m.add_total) + local del = ("-%d"):format(m.del_total) + local reserve = #add + #del + 2 + vim.api.nvim_buf_set_extmark(self.bufnr, ns, row, 0, { + virt_text = { + { add, "differPanelCountAdd" }, + { " ", "Normal" }, + { del, "differPanelCountDelete" }, + { " ", "Normal" }, + }, + virt_text_win_col = math.max((self.content_width or 0) - reserve, 0), + }) + end elseif m.kind == "footrev" then vim.api.nvim_buf_set_extmark( self.bufnr, diff --git a/lua/differ/panel/render.lua b/lua/differ/panel/render.lua index 354d0c1..ceae8b6 100644 --- a/lua/differ/panel/render.lua +++ b/lua/differ/panel/render.lua @@ -32,11 +32,15 @@ local FOLD_OPEN, FOLD_CLOSED = "▾", "▸" ---@field viewed_end integer|nil ---@field prefix_col integer|nil -- header rows: byte col where the dimmed common-prefix subtitle begins ---@field prefix_end integer|nil +---@field add_total integer|nil -- header rows: section additions, pinned right like the per-file counts +---@field del_total integer|nil -- header rows: section deletions ---@class differ.panel.Block ---@field title string|nil -- section header, nil = no header row ---@field prefix string|nil -- common dir stripped in tree mode, shown as a subtitle ---@field count integer|nil -- file count for the header; defaults to the visible file rows +---@field add integer|nil -- section additions total (fold-independent); nil skips the header aggregate +---@field del integer|nil -- section deletions total ---@field rows differ.panel.Row[] ---@param rows differ.panel.Row[] @@ -85,11 +89,25 @@ function M.lines(blocks, header, icon_for, footer, width) end for bi, block in ipairs(blocks) do if block.title or block.prefix then + -- breathe a blank line before each section so headers don't butt up + -- against the previous section's files. skipped when nothing precedes + -- (first block, no top header) or the previous line is already blank + -- (the top header's own trailing blank), so no double gap opens up + if #lines > 0 and lines[#lines] ~= "" then + lines[#lines + 1] = "" + meta[#meta + 1] = { kind = "blank" } + end -- count is the section's true file total (fold-independent); fall back -- to the visible file rows when the caller doesn't supply it (tests) local n = block.count or count_files(block.rows) local head = block.title and ("%s (%d)"):format(block.title, n) or "" - local m = { kind = "header", section = bi, title = block.title } + local m = { + kind = "header", + section = bi, + title = block.title, + add_total = block.add, + del_total = block.del, + } if block.prefix then local sep = head ~= "" and " · " or "" m.prefix_col = #head -- byte col where the dimmed " · prefix" begins diff --git a/test/nvim/panel_spec.lua b/test/nvim/panel_spec.lua index 389f552..4264d94 100644 --- a/test/nvim/panel_spec.lua +++ b/test/nvim/panel_spec.lua @@ -207,17 +207,19 @@ describe("panel navigation", function() on_select = function() end, }) p:open() - -- both sections carry a src/ dir; toggling one must not collapse the other - vim.api.nvim_win_set_cursor(p.winid, { 5, 0 }) -- the Untracked src/ row + -- both sections carry a src/ dir; toggling one must not collapse the other. + -- a blank line separates the sections, so the Untracked src/ row is line 6 + vim.api.nvim_win_set_cursor(p.winid, { 6, 0 }) -- the Untracked src/ row p:select() assert.are.same({ "Unstaged (1)", "▾ src/", " M a.lua", + "", -- section separator "Untracked (1)", "▸ src/", -- only this one folded }, lines(p)) - assert.are.equal(5, vim.api.nvim_win_get_cursor(p.winid)[1]) -- cursor stayed on it + assert.are.equal(6, vim.api.nvim_win_get_cursor(p.winid)[1]) -- cursor stayed on it p:close() end) diff --git a/test/unit/panel_render_spec.lua b/test/unit/panel_render_spec.lua index f0df306..2629602 100644 --- a/test/unit/panel_render_spec.lua +++ b/test/unit/panel_render_spec.lua @@ -43,6 +43,49 @@ describe("panel.render.lines", function() assert.are.equal("Unstaged (2)", out.lines[1]) end) + it("carries the section +/- aggregate onto the header meta", function() + -- block.add/block.del ride the header meta so the runtime layer can pin them + -- to the right edge (like the per-file counts); they never enter the line text + local root = tree.build({ entry("a.lua", "M", 3, 1), entry("b.lua", "M", 5, 0) }) + local out = render.lines({ + { title = "Staged", add = 8, del = 1, rows = tree.rows(root, "tree", {}) }, + }) + assert.are.equal("Staged (2)", out.lines[1]) + local m = out.meta[1] + assert.are.equal("header", m.kind) + assert.are.equal(8, m.add_total) + assert.are.equal(1, m.del_total) + end) + + it("leaves the header aggregate nil when the block omits add/del", function() + local root = tree.build({ entry("a.lua") }) + local out = render.lines({ { title = "Unstaged", rows = tree.rows(root, "tree", {}) } }) + assert.is_nil(out.meta[1].add_total) + assert.is_nil(out.meta[1].del_total) + end) + + it("separates consecutive sections with a blank line, none before the first", function() + local a = tree.build({ entry("a.lua") }) + local b = tree.build({ entry("b.lua") }) + local out = render.lines({ + { title = "Staged", rows = tree.rows(a, "tree", {}) }, + { title = "Unstaged", rows = tree.rows(b, "tree", {}) }, + }) + -- no leading blank (no top header precedes the first section) + assert.are.same({ "Staged (1)", "M a.lua", "", "Unstaged (1)", "M b.lua" }, out.lines) + assert.are.equal("blank", out.meta[3].kind) + end) + + it("does not double-blank a section that follows the top header's blank", function() + local root = tree.build({ entry("a.lua") }) + local out = render.lines( + { { title = "Staged", rows = tree.rows(root, "tree", {}) } }, + { path = "~/repo", help = "g?" } + ) + -- top header ends in a blank; the section must not add a second one + assert.are.same({ "~/repo", "Help: g?", "", "Staged (1)", "M a.lua" }, out.lines) + end) + it("renders a file row with status letter and points cols at it", function() local root = tree.build({ entry("a.lua", "M") }) local out = render.lines({ { title = nil, rows = tree.rows(root, "tree", {}) } }) From fb3ec2e136e7d391e95ab7d4747bdaee51949d7d Mon Sep 17 00:00:00 2001 From: orandvlp <143298175+orandvlp@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:57:43 +0100 Subject: [PATCH 2/3] docs: note per-section panel totals in changelog and readme --- CHANGELOG.md | 1 + README.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c8e05..b5c8146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - A review to overview round-trip: `go` pops from the review back to the overview, and `q` drops back into the review where you left off, restoring the stashed diff position and the overview's own cursor on the hop back - `df` edits the real file in an in-review split on the pinned-blob diff; `de` opens a zoom tab to edit and returns to the review on close - A floating keymap cheatsheet (`g?`) on the overview page, advertised by a `help: g?` header hint +- Per-section `+/-` totals on the file panel's section headers (Staged/Unstaged/Untracked), summed fold-independently and pinned to the same column as the per-file counts and the `--stat` help-line total. Consecutive sections are separated by a blank line so a header doesn't butt up against the previous section's files ### Fixed diff --git a/README.md b/README.md index eb2e50c..e661ca2 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The GitHub side runs in a separate process rather than the editor, so opening a - **Stacked dual-rail layout** with one scroll surface, old and new lines interleaved per hunk, and a dual line-number gutter via `statuscolumn` - **Side-by-side layout** from the same hunk model, switchable at runtime as a pure re-render - **PR review in the diff** with inline comment threads, pending-review drafts, thread resolve, per-file viewed-state, CI checks, and lifecycle actions (merge, checkout, ready/draft, close), backed by a Go sidecar that owns the GitHub API -- **File panel and staging** in a persistent sidebar with the changed-file tree, status icons, +/- counts, and hunk- and file-level staging +- **File panel and staging** in a persistent sidebar with the changed-file tree, status icons, +/- counts (per file and per section), and hunk- and file-level staging - **File history** for single files and branch ranges, walked commit-by-commit, each step a diff through the same engine - **3-way merge tool** running base/ours/theirs through the n-column renderer, resolved into the working-tree file - **Word-level highlighting** and **Treesitter syntax** on by default, so the diff reads like source instead of a grey block From 7803a5fa2ffd019601b50338052f69b1c77632cd Mon Sep 17 00:00:00 2001 From: orandvlp <143298175+orandvlp@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:29:30 +0100 Subject: [PATCH 3/3] feat: gate per-section panel diffstat behind panel.section_diffstat (off by default) The per-section +/- header totals are now opt-in via panel.section_diffstat, defaulting off. The header block only carries its add/del totals when enabled; the width reserve and virt_text extmark already guard on the totals, so a nil suppresses the aggregate and the header renders plain. --- CHANGELOG.md | 7 ++++++- README.md | 3 ++- lua/differ/config.lua | 4 ++++ lua/differ/git/init.lua | 1 + lua/differ/panel/init.lua | 9 +++++++-- lua/differ/pr/init.lua | 1 + test/nvim/panel_spec.lua | 25 +++++++++++++++++++++++++ 7 files changed, 46 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c8146..a3565a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- Per-section `+/-` totals on the file panel's section headers (Staged/Unstaged/Untracked), summed fold-independently and pinned to the same column as the per-file counts and the `--stat` help-line total. Opt-in via `panel.section_diffstat = true` (off by default). Consecutive sections are separated by a blank line so a header doesn't butt up against the previous section's files + +## [0.1.18] — 2026-07-13 + +### Added + - A PR overview page: the conversation timeline plus code threads rendered as boxed units (a left-spine box with a top-rule header, spine body rows, and a reply-count footer) that carry their diff hunk inline. The hunk tail is capped (keeping the `@@` header and an elision marker when trimmed), tinted with the diff's own +/- line colours, and treesitter-highlighted from the marker-stripped source rather than the page buffer. `]t`/`[t` hop between thread boxes - Enter the review straight from an overview thread: ``, `e`, or `r` on a thread row open the review at that comment's file and line, landing on the comment with no file-stepping - A review to overview round-trip: `go` pops from the review back to the overview, and `q` drops back into the review where you left off, restoring the stashed diff position and the overview's own cursor on the hop back - `df` edits the real file in an in-review split on the pinned-blob diff; `de` opens a zoom tab to edit and returns to the review on close - A floating keymap cheatsheet (`g?`) on the overview page, advertised by a `help: g?` header hint -- Per-section `+/-` totals on the file panel's section headers (Staged/Unstaged/Untracked), summed fold-independently and pinned to the same column as the per-file counts and the `--stat` help-line total. Consecutive sections are separated by a blank line so a header doesn't butt up against the previous section's files ### Fixed diff --git a/README.md b/README.md index e661ca2..428c680 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ The GitHub side runs in a separate process rather than the editor, so opening a - **Stacked dual-rail layout** with one scroll surface, old and new lines interleaved per hunk, and a dual line-number gutter via `statuscolumn` - **Side-by-side layout** from the same hunk model, switchable at runtime as a pure re-render - **PR review in the diff** with inline comment threads, pending-review drafts, thread resolve, per-file viewed-state, CI checks, and lifecycle actions (merge, checkout, ready/draft, close), backed by a Go sidecar that owns the GitHub API -- **File panel and staging** in a persistent sidebar with the changed-file tree, status icons, +/- counts (per file and per section), and hunk- and file-level staging +- **File panel and staging** in a persistent sidebar with the changed-file tree, status icons, +/- counts (per file, and per section via `panel.section_diffstat`), and hunk- and file-level staging - **File history** for single files and branch ranges, walked commit-by-commit, each step a diff through the same engine - **3-way merge tool** running base/ours/theirs through the n-column renderer, resolved into the working-tree file - **Word-level highlighting** and **Treesitter syntax** on by default, so the diff reads like source instead of a grey block @@ -386,6 +386,7 @@ require("differ").setup({ width = 35, -- left/right listing = "tree", -- "tree" | "name" progress = true, -- "file K/N" position meter in the panel winbar + section_diffstat = false, -- per-section "+A -B" totals on the section headers }, history = { -- log/history sidebar default placement/size position = "bottom", diff --git a/lua/differ/config.lua b/lua/differ/config.lua index 12ead69..f329363 100644 --- a/lua/differ/config.lua +++ b/lua/differ/config.lua @@ -6,6 +6,7 @@ ---@field width integer -- used for left/right ---@field listing "tree"|"name" ---@field progress boolean -- file-position meter in the panel winbar +---@field section_diffstat boolean -- per-section +/- totals pinned on the header row ---@class differ.Config.History ---@field position "bottom"|"top"|"left"|"right" @@ -66,6 +67,9 @@ M.defaults = { width = 35, -- left/right listing = "tree", progress = true, -- "file K/N" position meter in the panel winbar + -- per-section "+A -B" totals stacked above each section's file counts. + -- off by default: opt in for the extra header column + section_diffstat = false, }, -- the log/history sidebar's default placement and size. a commit row is wide -- (sha · date · author · subject), so it defaults to the bottom strip where the diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 8bb110a..03613b9 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1026,6 +1026,7 @@ function M.panel(opts) height = opts.height or panel_cfg.height, width = opts.width or panel_cfg.width, progress = panel_cfg.progress, + section_diffstat = panel_cfg.section_diffstat, on_select = function(entry) if show_entry(entry) then return diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index 98c00fa..46f1b97 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -64,6 +64,7 @@ local STATUS_HL = { ---@field origin_win integer|nil ---@field return_tab integer|nil ---@field progress boolean -- file-position meter in the panel winbar +---@field section_diffstat boolean -- per-section +/- totals on the header row ---@field sections differ.panel.Section[] ---@field listing "tree"|"name" ---@field collapsed table> -- section key -> dir path -> folded @@ -112,6 +113,7 @@ Panel.__index = Panel ---@field height? integer ---@field width? integer ---@field progress? boolean -- file-position meter in the panel winbar (default on) +---@field section_diffstat? boolean -- per-section +/- header totals (default off) ---@class differ.panel.ExtraMap ---@field spec string|string[]|false -- resolved lhs (a keymaps value) @@ -159,6 +161,7 @@ function Panel.new(opts) height = opts.height or 7, width = opts.width or 35, progress = opts.progress ~= false, -- default on; only an explicit false disables it + section_diffstat = opts.section_diffstat == true, -- default off; only an explicit true enables it collapsed = {}, lines = {}, meta = {}, @@ -260,8 +263,10 @@ function Panel:render() title = sec.title, prefix = strip ~= "" and strip or nil, count = #sec.entries, - add = sec_add, - del = sec_del, + -- header aggregate is opt-in: nil skips it, so render/highlight and the + -- width reserve all fall back to the plain header when it's off + add = self.section_diffstat and sec_add or nil, + del = self.section_diffstat and sec_del or nil, -- fold state is namespaced per section: the same dir path can list under -- two sections at once (e.g. an untracked src/ and an unstaged src/), so a -- shared key would collapse both rows from one toggle diff --git a/lua/differ/pr/init.lua b/lua/differ/pr/init.lua index 687a930..ff2caf4 100644 --- a/lua/differ/pr/init.lua +++ b/lua/differ/pr/init.lua @@ -768,6 +768,7 @@ local function open_session(pr, detail, opts) height = panel_cfg.height, width = panel_cfg.width, progress = panel_cfg.progress, + section_diffstat = panel_cfg.section_diffstat, on_select = function(entry) show_file(entry) end, diff --git a/test/nvim/panel_spec.lua b/test/nvim/panel_spec.lua index 4264d94..b44ec71 100644 --- a/test/nvim/panel_spec.lua +++ b/test/nvim/panel_spec.lua @@ -42,6 +42,31 @@ describe("panel rendering", function() p:close() end) + it("omits the section +/- aggregate by default, carries it when opted in", function() + local secs = { { title = "Staged", entries = { fe("a.lua", "M", 3, 1) } } } + local header = function(p) + for _, m in ipairs(p.meta) do + if m.kind == "header" then + return m + end + end + end + -- default: section_diffstat off, so the header meta carries no totals + local off = Panel.new({ sections = secs, on_select = function() end }) + off:render() + assert.is_nil(header(off).add_total) + assert.is_nil(header(off).del_total) + -- opted in: the fold-independent +/- totals ride the header meta + local on = Panel.new({ + sections = secs, + section_diffstat = true, + on_select = function() end, + }) + on:render() + assert.are.equal(3, header(on).add_total) + assert.are.equal(1, header(on).del_total) + end) + it("toggle_listing toggles tree <-> name", function() local p = panel({ fe("a.lua"), fe("src/b.lua") }) p:open()