diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e12163..9ec5f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lua/differ/nav.lua b/lua/differ/nav.lua index daff048..5e614c2 100644 --- a/lua/differ/nav.lua +++ b/lua/differ/nav.lua @@ -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 diff --git a/lua/differ/ui/paint.lua b/lua/differ/ui/paint.lua index ec69987..ce78293 100644 --- a/lua/differ/ui/paint.lua +++ b/lua/differ/ui/paint.lua @@ -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 diff --git a/lua/differ/view.lua b/lua/differ/view.lua index b26bc23..affc178 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -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 @@ -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 }) @@ -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 @@ -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 @@ -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) @@ -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 @@ -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( diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 8008984..5a7ab88 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -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) @@ -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) @@ -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 @@ -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() diff --git a/test/nvim/history_spec.lua b/test/nvim/history_spec.lua index 9fc5a1f..f722890 100644 --- a/test/nvim/history_spec.lua +++ b/test/nvim/history_spec.lua @@ -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 ) @@ -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) diff --git a/test/unit/nav_spec.lua b/test/unit/nav_spec.lua index b97a8a2..5ed6eaf 100644 --- a/test/unit/nav_spec.lua +++ b/test/unit/nav_spec.lua @@ -1,4 +1,5 @@ local stacked = require("differ.render.stacked") +local split = require("differ.render.split") local nav = require("differ.nav") -- two hunks far apart -> stacked (full context) buffer: @@ -209,3 +210,84 @@ describe("nav with no hunks", function() assert.is_nil(nav.last_hunk(map)) end) end) + +-- a moved pair: histogram diff reports a pure deletion then a pure insertion, so +-- each split column sees only one of them: +-- row old new +-- 1 a a +-- 2 /10 . h1 +-- 3 +11 . h1 +-- 4 *2 *2 +-- 5 . +11 h2 +-- 6 . /10 h2 +-- 7 z z +local function moved_pair_columns() + return split.render({ + path = "x", + old_rev = "A", + new_rev = "B", + old_text = "a\n/10\n+11\n*2\nz\n", + new_text = "a\n*2\n+11\n/10\nz\n", + hunks = { + { + old_start = 2, + old_count = 2, + new_start = 1, + new_count = 0, + old_lines = { "/10", "+11" }, + new_lines = {}, + }, + { + old_start = 4, + old_count = 0, + new_start = 3, + new_count = 2, + old_lines = {}, + new_lines = { "+11", "/10" }, + }, + }, + }, { context = math.huge }).columns +end + +describe("nav.merge", function() + local columns = moved_pair_columns() + local old_map, new_map = columns[1].map, columns[2].map + + it("leaves each column seeing one hunk", function() + assert.are.equal(2, nav.first_hunk(old_map)) + assert.are.equal(2, nav.last_hunk(old_map)) + assert.are.equal(5, nav.first_hunk(new_map)) + assert.are.equal(5, nav.last_hunk(new_map)) + end) + + it("resolves filler rows to the hunk beside them", function() + local map = nav.merge({ old_map, new_map }) + assert.are.equal(1, map.lines[2].hunk) + assert.are.equal(2, map.lines[5].hunk) + assert.are.equal(2, nav.next_hunk(map, 0)) + assert.are.equal(5, nav.next_hunk(map, 2)) + assert.are.equal(2, nav.prev_hunk(map, 5)) + assert.are.equal(5, nav.last_hunk(map)) + end) + + it("keeps both sides' reverse indices", function() + local map = nav.merge({ old_map, new_map }) + assert.are.equal(2, map.from_old[2]) + assert.are.equal(5, map.from_new[3]) + assert.are.equal(7, map.from_old[5]) + assert.are.equal(7, map.from_new[5]) + end) + + it("still applies the filter", function() + local map = nav.merge({ old_map, new_map }) + local function second(h) + return h == 2 + end + assert.are.equal(5, nav.first_hunk(map, second)) + assert.is_nil(nav.prev_hunk(map, 5, second)) + end) + + it("passes a single map through", function() + assert.are.equal(old_map, nav.merge({ old_map })) + end) +end)