Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ 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: `<CR>`, `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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions lua/differ/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lua/differ/git/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions lua/differ/panel/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, table<string, boolean>> -- section key -> dir path -> folded
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {},
Expand Down Expand Up @@ -243,18 +246,27 @@ 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,
-- 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
Expand Down Expand Up @@ -288,6 +300,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))
Expand Down Expand Up @@ -375,6 +394,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,
Expand Down
20 changes: 19 additions & 1 deletion lua/differ/panel/render.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lua/differ/pr/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 30 additions & 3 deletions test/nvim/panel_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -207,17 +232,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)

Expand Down
43 changes: 43 additions & 0 deletions test/unit/panel_render_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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", {}) } })
Expand Down