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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed

- In split layout, `s`, `u`, `X` and `]c`/`[c` skipped a pure insertion from the old column and a pure deletion from the new one
- A visual selection didn't show on split filler rows

## [0.2.6] — 2026-10-19

### Added

- `s` and `u` in visual mode stage and unstage only the selected lines of the diff
Expand Down
38 changes: 38 additions & 0 deletions lua/differ/nav.lua
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,46 @@
-- narrows it to hunks in a given state, which is how the staging review flow
-- searches for the next hunk left to stage rather than the next hunk

local LineMap = require("differ.render.linemap")

local M = {}

-- row `lnum` of the first map whose row carries a hunk, else the first map's
---@param maps differ.LineMap[]
---@param lnum integer
---@return differ.RailLine
local function hunk_row(maps, lnum)
for _, map in ipairs(maps) do
if map.lines[lnum].hunk then
return map.lines[lnum]
end
end
return maps[1].lines[lnum]
end

-- one map over row-aligned column maps (split), so a filler row resolves to the
-- hunk beside it. a single map passes through
---@param maps differ.LineMap[]
---@return differ.LineMap
function M.merge(maps)
if #maps == 1 then
return maps[1]
end
local merged = LineMap.new()
for lnum = 1, #maps[1].lines do
merged.lines[lnum] = hunk_row(maps, lnum)
end
for _, map in ipairs(maps) do
for old, lnum in pairs(map.from_old) do
merged.from_old[old] = lnum
end
for new, lnum in pairs(map.from_new) do
merged.from_new[new] = lnum
end
end
return merged
end

-- the start of each hunk block: the first line whose `hunk` index differs from the
-- line above it, paired with that index. ascending by lnum
---@param map differ.LineMap
Expand Down
1 change: 1 addition & 0 deletions lua/differ/ui/paint.lua
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function M.apply(bufnr, ns, column)
vim.api.nvim_buf_set_extmark(bufnr, ns, row, 0, {
virt_text = { { string.rep("-", vim.o.columns), "differFiller" } },
virt_text_win_col = 0,
hl_mode = "combine",
priority = 100,
})
end
Expand Down
41 changes: 26 additions & 15 deletions lua/differ/view.lua
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,15 @@ function View:_focused_column()
return self.columns[1]
end

-- every column's hunks on one map, so a split row resolves to its hunk from either
-- column, filler rows included. stacked has one column and passes it through
---@return differ.LineMap
function View:_hunk_map()
return nav.merge(vim.tbl_map(function(c)
return c.map
end, self.columns))
end

local function run_hunk_fallback(direction, opts)
return opts and opts.fallback and opts.fallback(direction)
end
Expand All @@ -1009,11 +1018,12 @@ function View:goto_hunk(direction, opts)
local lnum = vim.api.nvim_win_get_cursor(col.winid or win)[1]
-- explicit branch, not `a and next() or prev()`: next_hunk returns nil at the
-- last hunk, which the and/or idiom would wrongly fall through to prev_hunk
local map = self:_hunk_map()
local target
if direction == "next" then
target = nav.next_hunk(col.map, lnum)
target = nav.next_hunk(map, lnum)
else
target = nav.prev_hunk(col.map, lnum)
target = nav.prev_hunk(map, lnum)
end
if target then
return vim.api.nvim_win_set_cursor(col.winid or win, { target, 0 })
Expand Down Expand Up @@ -1062,10 +1072,10 @@ end
-- the buffer line to land on when a file opens: the start of the first
-- unstaged hunk, the natural place to begin reviewing, falling back to the first
-- hunk when everything is already staged, or nil for a file with no hunks
---@param col differ.ViewColumn
---@return integer|nil
function View:_first_review_line(col)
return nav.first_hunk(col.map, self:_review_filter(false)) or nav.first_hunk(col.map)
function View:_first_review_line()
local map = self:_hunk_map()
return nav.first_hunk(map, self:_review_filter(false)) or nav.first_hunk(map)
end

-- move the primary window's cursor to the first unstaged hunk (or first hunk). run
Expand All @@ -1076,7 +1086,7 @@ function View:_focus_first_hunk()
if not (col and col.winid and vim.api.nvim_win_is_valid(col.winid)) then
return
end
local lnum = self:_first_review_line(col)
local lnum = self:_first_review_line()
if lnum then
pcall(vim.api.nvim_win_set_cursor, col.winid, { lnum, 0 })
end
Expand Down Expand Up @@ -1161,10 +1171,10 @@ end
-- the mirror of _first_review_line: the start of the last hunk still staged, where the
-- backward review flow lands when it steps into another file (so u keeps unstaging
-- through it), falling back to the last hunk when none of them are staged
---@param col differ.ViewColumn
---@return integer|nil
function View:_last_review_line(col)
return nav.last_hunk(col.map, self:_review_filter(true)) or nav.last_hunk(col.map)
function View:_last_review_line()
local map = self:_hunk_map()
return nav.last_hunk(map, self:_review_filter(true)) or nav.last_hunk(map)
end

-- move the primary window's cursor to the last staged hunk (backward file-step landing)
Expand All @@ -1173,19 +1183,19 @@ function View:_focus_last_hunk()
if not (col and col.winid and vim.api.nvim_win_is_valid(col.winid)) then
return
end
local lnum = self:_last_review_line(col)
local lnum = self:_last_review_line()
if lnum then
pcall(vim.api.nvim_win_set_cursor, col.winid, { lnum, 0 })
end
end

-- the index of the hunk the cursor sits in, via the focused column's map
-- (staging). nil on a context / meta / unchanged line that belongs to no hunk
-- the index of the hunk on the cursor's row (staging). nil on a context / meta /
-- unchanged row that belongs to no hunk in any column
---@return integer|nil
function View:_hunk_index_under_cursor()
local col = self:_focused_column()
local win = col.winid or vim.api.nvim_get_current_win()
local line = col.map.lines[vim.api.nvim_win_get_cursor(win)[1]]
local line = self:_hunk_map().lines[vim.api.nvim_win_get_cursor(win)[1]]
return line and line.hunk or nil
end

Expand Down Expand Up @@ -1568,12 +1578,13 @@ function View:_step_review(direction)
local lnum = vim.api.nvim_win_get_cursor(win)[1]
local want = self:_review_filter(staged)

local onward = (forward and nav.next_hunk or nav.prev_hunk)(col.map, lnum, want)
local map = self:_hunk_map()
local onward = (forward and nav.next_hunk or nav.prev_hunk)(map, lnum, want)
if onward then
return vim.api.nvim_win_set_cursor(win, { onward, 0 })
end

local back = (forward and nav.first_hunk or nav.last_hunk)(col.map, want)
local back = (forward and nav.first_hunk or nav.last_hunk)(map, want)
if back then
vim.api.nvim_win_set_cursor(win, { back, 0 })
return vim.notify(
Expand Down
136 changes: 130 additions & 6 deletions test/nvim/git_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -5823,7 +5823,7 @@ func getDownloadSpeed() {
local p, v = open_panel()
local cur = vim.api.nvim_win_get_cursor(p.origin_win)[1]
assert.is_not_nil(v.columns[1].map.lines[cur].hunk) -- on a hunk, not context
assert.are.equal(v:_first_review_line(v.columns[1]), cur)
assert.are.equal(v:_first_review_line(), cur)
p:close()
end)

Expand All @@ -5835,11 +5835,11 @@ func getDownloadSpeed() {
vim.cmd.edit(root .. "/a.lua")

local p, v = open_panel()
assert.are.equal(1, v:_first_review_line(v.columns[1])) -- both unstaged -> hunk 1
assert.are.equal(1, v:_first_review_line()) -- both unstaged -> hunk 1

vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 })
v:stage_hunk() -- stage hunk 1; the next place to review is hunk 2 (buffer line 9)
assert.are.equal(9, v:_first_review_line(v.columns[1]))
assert.are.equal(9, v:_first_review_line())
p:close()
end)

Expand Down Expand Up @@ -7206,6 +7206,130 @@ describe(":Differ panel (zero-hunk entries)", function()
end)
end)

-- split pairs a pure deletion with filler rows in the new column and a pure insertion
-- with filler in the old one; staging and hunk nav find those hunks from either column
describe(":Differ diff split hunks across filler rows", function()
local TWENTY = {}
for i = 1, 20 do
TWENTY[i] = tostring(i)
end
local BASE = table.concat(TWENTY, "\n") .. "\n"

-- a.lua with line 3 deleted (hunk 1) and a line added after 15 (hunk 2), opened
-- in split. `seed` adds to the repo before the panel lists it
---@param seed? fun(root: string)
local function split_repo(seed)
local root = fresh_repo()
write(root .. "/a.lua", BASE)
git(root, "commit", "-q", "-am", "twenty lines")
local lines = vim.list_slice(TWENTY, 1, 2)
vim.list_extend(lines, vim.list_slice(TWENTY, 4, 15))
lines[#lines + 1] = "15a"
vim.list_extend(lines, vim.list_slice(TWENTY, 16, 20))
write(root .. "/a.lua", table.concat(lines, "\n") .. "\n")
if seed then
seed(root)
end
vim.cmd.edit(root .. "/a.lua")
local p, v = open_panel()
v:toggle_layout()
assert.are.equal(2, #v.columns)
return root, p, v
end
local function indexed(root)
return git(root, "show", ":a.lua")
end
-- the rows of the deletion and the insertion, the same in both columns
local function rows(v)
return v.columns[1].map.from_old[3], v.columns[2].map.from_new[15]
end
local function at(col, row)
vim.api.nvim_set_current_win(col.winid)
vim.api.nvim_win_set_cursor(col.winid, { row, 0 })
end
local function cursor(col)
return vim.api.nvim_win_get_cursor(col.winid)[1]
end

it("s from the old column steps past a staged deletion to a pure insertion", function()
local _, p, v = split_repo()
local old = v.columns[1]
local del, ins = rows(v)
at(old, del)
v:stage_hunk()
assert.are.equal("staged", v:_hunk_state(1))

v:stage_hunk()
assert.are.equal(old.winid, vim.api.nvim_get_current_win())
assert.are.equal(ins, cursor(old))
p:close()
end)

it("s on a pure insertion's filler row in the old column stages it", function()
local root, p, v = split_repo()
local _, ins = rows(v)
at(v.columns[1], ins)
assert.are.equal(2, v:_hunk_index_under_cursor())

v:stage_hunk()
assert.are.equal("unstaged", v:_hunk_state(1))
assert.are.equal("staged", v:_hunk_state(2))
assert.is_truthy(indexed(root):find("15\n15a\n16\n", 1, true))
p:close()
end)

it("]c from the old column visits a pure insertion", function()
local _, p, v = split_repo()
local old = v.columns[1]
local del, ins = rows(v)
at(old, 1)
v:goto_hunk("next")
assert.are.equal(del, cursor(old))
v:goto_hunk("next")
assert.are.equal(ins, cursor(old))
v:goto_hunk("prev")
assert.are.equal(del, cursor(old))
p:close()
end)

it("]c and s from the new column reach a pure deletion", function()
local root, p, v = split_repo()
local new = v.columns[2]
local del, ins = rows(v)
at(new, 1)
v:goto_hunk("next")
assert.are.equal(del, cursor(new))
assert.are.equal(1, v:_hunk_index_under_cursor())

v:stage_hunk()
assert.are.equal("staged", v:_hunk_state(1))
assert.is_nil(indexed(root):find("\n3\n", 1, true))

v:stage_hunk()
assert.are.equal(ins, cursor(new))
p:close()
end)

it("a file opening on a pure insertion lands on it in split", function()
local _, p, v = split_repo(function(root)
write(root .. "/b.lua", "one\n")
git(root, "add", "b.lua")
git(root, "commit", "-q", "-m", "b")
write(root .. "/b.lua", "one\n" .. BASE)
end)
assert.are.equal("a.lua", v.model.path)
v:step_file("next")
v = view_in_origin(p)
assert.are.equal("b.lua", v.model.path)
assert.are.equal(2, #v.columns)

local old = v.columns[1]
assert.are.equal(v.columns[2].map.from_new[2], cursor(old))
assert.are.equal(1, v:_hunk_index_under_cursor())
p:close()
end)
end)

-- a whole-file source (a new or deleted file, a binary one, a change with no lines
-- to show) stages as a unit. the keys act on the file rather than on the hunk under
-- the cursor, which is what used to send them stepping off to another file
Expand Down Expand Up @@ -7276,11 +7400,11 @@ describe(":Differ diff whole-file staging", function()
v:toggle_layout() -- -> split; the old column is all filler
assert.are.equal(2, #v.columns)

-- a pure add carries no old lines, so every old-column row is a meta row with
-- no hunk on it: the cursor lookup finds nothing there
-- a pure add carries no old lines, so every old-column row is filler; the row
-- still resolves to the add through the new column
vim.api.nvim_set_current_win(v.columns[1].winid)
vim.api.nvim_win_set_cursor(v.columns[1].winid, { 1, 0 })
assert.is_nil(v:_hunk_index_under_cursor())
assert.are.equal(1, v:_hunk_index_under_cursor())

v:stage_hunk()

Expand Down
4 changes: 2 additions & 2 deletions test/nvim/history_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ describe(":Differ log (single-file history)", function()
local v = view_in_origin(h)
local col = v.columns[1]
local cur = vim.api.nvim_win_get_cursor(h.origin_win)[1]
assert.are.equal(v:_first_review_line(col), cur) -- first hunk, origin not re-applied
assert.are.equal(v:_first_review_line(), cur) -- first hunk, origin not re-applied
h:close()
end
)
Expand All @@ -254,7 +254,7 @@ describe(":Differ log (single-file history)", function()
local v = view_in_origin(h)
local col = v.columns[1]
local cur = vim.api.nvim_win_get_cursor(h.origin_win)[1]
assert.are.equal(v:_first_review_line(col), cur) -- first hunk, origin ignored
assert.are.equal(v:_first_review_line(), cur) -- first hunk, origin ignored
h:close()
end)

Expand Down
Loading
Loading