From d2aa225ee49a25966dc66cde1645d520c32ab69e Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:01:30 +0100 Subject: [PATCH 1/6] feat: build index text for a line selection --- lua/differ/model/stage.lua | 110 +++++++++++++++++++++++++++++++++ test/unit/model_stage_spec.lua | 98 +++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) diff --git a/lua/differ/model/stage.lua b/lua/differ/model/stage.lua index 2c6cc7b..966e4b4 100644 --- a/lua/differ/model/stage.lua +++ b/lua/differ/model/stage.lua @@ -110,6 +110,116 @@ function M.next_index(union, cached, unstaged, hunk, take) return text end +---@class differ.model.Selection +---@field old table -- union old lnums +---@field new table -- union new lnums + +---@alias differ.model.SelectRefusal +---| "between" -- the index changes a line between hunks, so it can't be cut into blocks +---| "large" -- a hunk too big to compare against its block +---| "hidden" -- a selected hunk's block holds a line neither side has + +-- whether the selection has a line on either side of `h` +---@param h differ.Hunk +---@param selection differ.model.Selection +---@return boolean +local function touches(h, selection) + for l = h.old_start, h.old_start + h.old_count - 1 do + if selection.old[l] then + return true + end + end + for l = h.new_start, h.new_start + h.new_count - 1 do + if selection.new[l] then + return true + end + end + return false +end + +-- one hunk's index block with the selected lines staged (`take`) or not. sides of the +-- same length go row by row, each kept old line then its held new line; otherwise the +-- old lines the index keeps, then the new lines it holds +---@param h differ.Hunk -- an ended hunk +---@param held differ.model.Marks +---@param selection differ.model.Selection +---@param take boolean +---@return string[] +local function select_block(h, held, selection, take) + ---@param side "old"|"new" + ---@param k integer + ---@return boolean -- the index has this line once the op is done + local function in_index(side, k) + local l = h[side .. "_start"] + k - 1 + local staged = held[side][l] + if selection[side][l] then + staged = take + end + if side == "old" then + return not staged + end + return staged == true + end + local block = {} + ---@param side "old"|"new" + ---@param k integer + local function push(side, k) + if in_index(side, k) then + block[#block + 1] = h[side .. "_lines"][k] + end + end + if h.old_count == h.new_count then + for k = 1, h.old_count do + push("old", k) + push("new", k) + end + return block + end + for k = 1, h.old_count do + push("old", k) + end + for k = 1, h.new_count do + push("new", k) + end + return block +end + +-- the text the index takes when the selected union lines are staged (`take`) or +-- unstaged. only the hunks the selection touches are rebuilt; every other block stays +-- as the index has it. nil text with no refusal is a selection with nothing to move +---@param union differ.DiffModel -- HEAD↔worktree +---@param cached differ.DiffModel -- HEAD↔index +---@param selection differ.model.Selection +---@param take boolean +---@return string|nil text, differ.model.SelectRefusal|nil refusal +function M.select_index(union, cached, selection, take) + local blocks, ended = M.index_blocks(union, cached.new_text) + if not blocks then + return nil, "between" + end + local held, hidden = marks.of_blocks(ended.hunks, blocks) + if not held then + return nil, "large" + end + local hides = {} ---@type table + for _, i in ipairs(hidden) do + hides[i] = true + end + for i, h in ipairs(ended.hunks) do + if touches(h, selection) then + if hides[i] then + return nil, "hidden" + end + blocks[i] = select_block(h, held, selection, take) + end + end + local text = apply.join(ended, blocks) + if text == cached.new_text then + return nil + end + return text +end + -- the index text with its lines `first`..`last` back to HEAD's, where they sit inside -- HEAD↔index hunk `ci` and that hunk's two sides are the same length, so each index line -- has one HEAD line at the same place in it. every other hunk stays applied diff --git a/test/unit/model_stage_spec.lua b/test/unit/model_stage_spec.lua index 5efc684..ac83b79 100644 --- a/test/unit/model_stage_spec.lua +++ b/test/unit/model_stage_spec.lua @@ -139,3 +139,101 @@ describe("model.stage.drop_lines", function() assert.are.equal("1\n2", stage.drop_lines(cached, 1, 2, 2)) end) end) + +describe("model.stage.select_index", function() + local function sel(old, new) + local out = { old = {}, new = {} } + for _, l in ipairs(old) do + out.old[l] = true + end + for _, l in ipairs(new) do + out.new[l] = true + end + return out + end + + it("stages part of an insertion", function() + local union = model("1\n2\n", "1\nA\nB\nC\n2\n", { h(1, {}, 2, { "A", "B", "C" }) }) + local cached = model("1\n2\n", "1\n2\n", {}) + assert.are.equal("1\nB\n2\n", stage.select_index(union, cached, sel({}, { 3 }), true)) + end) + + it("stages part of a deletion", function() + local union = model("1\na\nb\nc\n2\n", "1\n2\n", { h(2, { "a", "b", "c" }, 1, {}) }) + local cached = model("1\na\nb\nc\n2\n", "1\na\nb\nc\n2\n", {}) + local text = stage.select_index(union, cached, sel({ 3 }, {}), true) + assert.are.equal("1\na\nc\n2\n", text) + end) + + -- HEAD 1 a b 2, worktree 1 x y 2: rows a|x and b|y + local SPLIT_HEAD, SPLIT_WORK = "1\na\nb\n2\n", "1\nx\ny\n2\n" + local function split_pair(index) + local union = model(SPLIT_HEAD, SPLIT_WORK, { h(2, { "a", "b" }, 2, { "x", "y" }) }) + return union, model(SPLIT_HEAD, index, {}) + end + + it("splits a replacement of equal length row by row", function() + local union, cached = split_pair(SPLIT_HEAD) + local text = stage.select_index(union, cached, sel({ 2 }, { 2 }), true) + assert.are.equal("1\nx\nb\n2\n", text) + end) + + it("stages the rest of a hunk staged in part", function() + local union, cached = split_pair("1\nx\nb\n2\n") + local text = stage.select_index(union, cached, sel({ 3 }, { 3 }), true) + assert.are.equal(SPLIT_WORK, text) + end) + + it("unstages part of a staged hunk", function() + local union, cached = split_pair(SPLIT_WORK) + local text = stage.select_index(union, cached, sel({ 3 }, { 3 }), false) + assert.are.equal("1\nx\nb\n2\n", text) + end) + + it("puts held new lines after the kept old lines when the sides differ in length", function() + local head = "1\na\nb\nc\n2\n" + local union = model(head, "1\nx\n2\n", { h(2, { "a", "b", "c" }, 2, { "x" }) }) + local text = stage.select_index(union, model(head, head, {}), sel({}, { 2 }), true) + assert.are.equal("1\na\nb\nc\nx\n2\n", text) + end) + + it("ends an unterminated HEAD line once a line follows it", function() + local union = model("1\na", "1\nx\ny\n", { h(2, { "a" }, 2, { "x", "y" }) }) + local cached = model("1\na", "1\na", {}) + assert.are.equal("1\na\nx\n", stage.select_index(union, cached, sel({}, { 2 }), true)) + end) + + it("leaves a worktree's unterminated last line unterminated", function() + local union = model("1\na\n", "1\nx\ny", { h(2, { "a" }, 2, { "x", "y" }) }) + local cached = model("1\na\n", "1\na\n", {}) + assert.are.equal("1\na\ny", stage.select_index(union, cached, sel({}, { 3 }), true)) + end) + + it("keeps the index's block at every hunk the selection misses", function() + local union, cached = pairs_of("1\n2\n3\n4\n5y\n") + local text = stage.select_index(union, cached, sel({ 2 }, { 2 }), true) + assert.are.equal(WORK, text) + end) + + it("gives nothing for a selection with nothing to move", function() + local union, cached = pairs_of(HEAD) + local text, refusal = stage.select_index(union, cached, sel({ 3 }, { 3 }), true) + assert.is_nil(text) + assert.is_nil(refusal) + end) + + it("refuses when the index changes a line between hunks", function() + local union, cached = pairs_of("1\n2\n3x\n4\n5\n") + local text, refusal = stage.select_index(union, cached, sel({ 2 }, { 2 }), true) + assert.is_nil(text) + assert.are.equal("between", refusal) + end) + + it("refuses a hunk whose index block holds a line neither side has", function() + local union = model("1\na\n2\n", "1\nx\n2\n", { h(2, { "a" }, 2, { "x" }) }) + local cached = model("1\na\n2\n", "1\nz\n2\n", {}) + local text, refusal = stage.select_index(union, cached, sel({}, { 2 }), true) + assert.is_nil(text) + assert.are.equal("hidden", refusal) + end) +end) From 192f6d42c666faa602ad7f5786801d6a23f8681f Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:11:41 +0100 Subject: [PATCH 2/6] feat: apply a line selection to the index --- lua/differ/git/staging.lua | 46 ++++++++++++++++++++++++++++++++++++++ lua/differ/view.lua | 1 + 2 files changed, 47 insertions(+) diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua index beaa647..aff6ef6 100644 --- a/lua/differ/git/staging.lua +++ b/lua/differ/git/staging.lua @@ -232,6 +232,23 @@ function M.new(ctx) return msg:format(reaches) end + -- why a selection can't move, staged (`take`) or not + ---@type table + local SELECT_REFUSALS = { + between = { + stage = "these lines can't be staged on their own here; stage them from dw", + unstage = "these lines can't be unstaged on their own here; unstage them from ds", + }, + large = { + stage = "this file's changes are too large to stage by line; s stages a hunk whole", + unstage = "this file's changes are too large to unstage by line; u unstages a hunk whole", + }, + hidden = { + stage = "this hunk holds staged content it can't show; s stages it whole", + unstage = "this hunk holds staged content it can't show; u unstages it whole", + }, + } + -- whether the diff still shows the file as it is. one drawn before the file or -- HEAD moved would place an op by stale line numbers, so it refuses and re-reads ---@param model differ.DiffModel -- the view's @@ -275,6 +292,35 @@ function M.new(ctx) remark(gitmod.union_pairs(root, entry.path, union.old_text, text, union.new_text)) return true end + -- s and u on a visual selection: those lines alone move. lines already where the + -- op would put them leave the index as it is + staging.apply_lines = function(model, selection, reverse) + local union, cached = gitmod.union_models(root, entry) + if not (union and cached) then + return index_unreadable(entry) + end + if not drawn_current(model, union) then + return false + end + local take = not reverse + local text, refused = stage.select_index(union, cached, selection, take) + if refused then + local msg = SELECT_REFUSALS[refused].unstage + if take then + msg = SELECT_REFUSALS[refused].stage + end + notify(msg, vim.log.levels.WARN) + return false, true + end + if not text then + return false + end + if not put_index(entry, text) then + return false + end + remark(gitmod.union_pairs(root, entry.path, union.old_text, text, union.new_text)) + return true + end -- X on a hunk whose staged change reaches another can't take it alone, and a -- drawing the file has moved on from places it by stale lines: both said before -- the prompt, which says as much itself diff --git a/lua/differ/view.lua b/lua/differ/view.lua index eec4613..7a87108 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -82,6 +82,7 @@ local armed_view = nil -- the second return says the op refused because this hunk can't move on its own, as -- against a failure: the source has named the view that can take it whole ---@field apply? fun(model: differ.DiffModel, hunk: differ.Hunk|nil, reverse: boolean): boolean, boolean|nil +---@field apply_lines? fun(model: differ.DiffModel, lines: differ.model.Selection, reverse: boolean): boolean, boolean? ---@field revert? fun(model: differ.DiffModel, idx: integer): boolean ---@field refuses_revert? fun(model: differ.DiffModel, idx: integer): boolean -- said why, before X's prompt ---@field revert_label? string -- e.g. "deletes the file" From 061c24f5d1ea99c49a5580c513b2f6be1c364bb2 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:35:48 +0100 Subject: [PATCH 3/6] feat: stage and unstage a visual selection --- lua/differ/git/staging.lua | 5 +++ lua/differ/view.lua | 69 ++++++++++++++++++++++++++++++ test/nvim/git_spec.lua | 87 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua index aff6ef6..d811425 100644 --- a/lua/differ/git/staging.lua +++ b/lua/differ/git/staging.lua @@ -313,6 +313,11 @@ function M.new(ctx) return false, true end if not text then + local state = "unstaged" + if take then + state = "staged" + end + notify("these lines are already " .. state, vim.log.levels.INFO) return false end if not put_index(entry, text) then diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 7a87108..7b7f6b2 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -837,6 +837,12 @@ function View:_setup_window(winid, bufnr) bind(bufnr, km.unstage, function() self:unstage_hunk() end, "differ: unstage hunk") + bind(bufnr, km.stage, function() + self:stage_lines() + end, "differ: stage selected lines", "x") + bind(bufnr, km.unstage, function() + self:unstage_lines() + end, "differ: unstage selected lines", "x") bind(bufnr, km.stage_all, function() self:stage_all() end, "differ: stage all hunks") @@ -938,6 +944,9 @@ function View:show_help() rows[#rows + 1] = { pair(km.stage, km.unstage), "stage / unstage " .. unit } rows[#rows + 1] = { pair(km.stage_all, km.unstage_all), "stage / unstage all" } end + if self:_can_stage_lines() then + rows[#rows + 1] = { pair(km.stage, km.unstage), "stage / unstage selected lines (visual)" } + end if self.staging and self.staging.revert then rows[#rows + 1] = { fmt(km.discard), "revert hunk (confirm)" } end @@ -1185,6 +1194,11 @@ function View:_can_stage_hunk() return self.can_stage and self.staging ~= nil and self.staging.apply ~= nil end +---@return boolean +function View:_can_stage_lines() + return self:_can_stage_hunk() and self.staging.apply_lines ~= nil +end + -- whether this file stages as one unit (e.g. a new/deleted file, a binary one, -- a change with no lines to show). ---@return boolean @@ -1333,6 +1347,61 @@ function View:unstage_hunk() end end +-- the changed lines in the visual selection, leaving visual mode. a split row range +-- covers both columns, so a row pairing a deleted and an added line takes both +---@return differ.model.Selection|nil +function View:_selected_lines() + local r1, r2 = vim.fn.line("v"), vim.fn.line(".") + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", false) + if r1 > r2 then + r1, r2 = r2, r1 + end + local selection = { old = {}, new = {} } + local any = false + for _, col in ipairs(self.columns) do + for row = r1, r2 do + local line = col.map.lines[row] + if line and line.kind == "old" then + selection.old[line.old] = true + any = true + elseif line and line.kind == "new" then + selection.new[line.new] = true + any = true + end + end + end + if not any then + return nil + end + return selection +end + +-- s / u on a visual selection: those lines alone move, and the cursor stays put +---@param want_staged boolean +function View:_toggle_lines(want_staged) + local selection = self:_selected_lines() + if self:_whole_file() then + return vim.notify("differ: this file stages whole, not by line", vim.log.levels.WARN) + end + if not self:_can_stage_lines() then + return vim.notify("differ: line staging isn't available here", vim.log.levels.WARN) + end + if not selection then + return vim.notify("differ: no changed lines selected", vim.log.levels.WARN) + end + if self.staging.apply_lines(self.model, selection, not want_staged) then + self:_after_staging() + end +end + +function View:stage_lines() + self:_toggle_lines(true) +end + +function View:unstage_lines() + self:_toggle_lines(false) +end + -- ask `prompt` before an op that drops content, and whether to go ahead. the prompt -- drains scheduled callbacks, so the model is checked again after it ---@param prompt string diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index c6b8b70..8008984 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2993,6 +2993,93 @@ func getDownloadSpeed() { p:close() end) + -- select buffer rows `first`..`last` of `col` and press `keys` (s or u) + local function visual(col, first, last, keys) + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { first, 0 }) + vim.api.nvim_feedkeys(("V%dG%s"):format(last, keys), "mx", false) + end + local function last_said() + return (_G.notifs[#_G.notifs] or {}).msg or "" + end + + it("visual s in split takes both halves of a row, and s takes the rest", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3x\n4x\n5\n6\n7\n8\n9\n10\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + v:toggle_layout() + assert.are.equal(2, #v.columns) + + local row = v.columns[1].map.from_old[3] + assert.are.equal(row, v.columns[2].map.from_new[3]) + visual(v.columns[2], row, row, "s") + assert.are.equal("1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n", indexed(root, "a.lua")) + assert.are.equal("partial", v:_hunk_state(1)) + + visual(v.columns[2], row, row, "s") + assert.is_truthy(last_said():find("already staged", 1, true)) + assert.are.equal("1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n", indexed(root, "a.lua")) + + v:stage_hunk() + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) + p:close() + end) + + it("visual s in stacked can take the added line alone", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + assert.are.equal(1, #v.columns) + + local col = v.columns[1] + local row = col.map.from_new[3] + visual(col, row, row, "s") + assert.are.equal("1\n2\n3\n3x\n4\n5\n6\n7\n8\n9\n10\n", indexed(root, "a.lua")) + assert.are.equal("partial", v:_hunk_state(1)) + p:close() + end) + + it("visual u gives back part of a staged hunk", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3x\n4x\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\n3x\n4x\n5\n6\n7\n8\n9y\n10\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + assert.are.equal("staged", v:_hunk_state(1)) + + v:toggle_layout() + local row = v.columns[1].map.from_old[4] + visual(v.columns[1], row, row, "u") + assert.are.equal("1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n", indexed(root, "a.lua")) + assert.are.equal("partial", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) + p:close() + end) + + it("visual s on a file that stages whole says so and stages nothing", function() + local root = fresh_repo() + write(root .. "/new.lua", "one\ntwo\n") + vim.cmd.edit(root .. "/new.lua") + local p, v = open_panel() + assert.are.equal("new.lua", v.model.path) + + local col = v.columns[1] + visual(col, 1, #col.map.lines, "s") + assert.is_truthy(last_said():find("stages whole", 1, true)) + assert.are.equal("", git(root, "ls-files", "--", "new.lua")) + p:close() + end) + -- a staged insertion sits after line 2, and the edit below it starts at line 3: the -- one hunk they merge into holds both, so u and X reach the insertion too ---@param root string From f67280024fba8441b12195d0b5046a02b2ac0077 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:14:32 +0100 Subject: [PATCH 4/6] docs: document visual stage and unstage --- README.md | 2 ++ doc/differ.txt | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index 6965a2c..b548661 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,8 @@ Sources with worktree on the new side (`:Differ`, `:Differ `, `:Differ . Every changed path takes only one row. Its diff spans `HEAD` to the worktree, with the lines already in the index shaded, and each row can either be `Conflicts`, `Staged`, `Unstaged`, `Partial` (not fully staged file) or `Untracked`. In the diff, `s` and `u` stage and unstage a hunk respectively, with repeated presses walking through/back your hunks; `X` discards a hunk on both sides (effectively hard resetting the hunk). Similarly in the panel, `s` and `u` stage and unstage entire rows/files (works on sections, directories and individual rows). `S` and `U` are available in both contexts as stage/unstage-all. +In visual mode, `s` and `u` stage and unstage only the selected lines. In split-view, a selection takes whole rows, so a modified line moves with both its halves. In stacked-view, the old and new lines are rows of their own, so you can take one side alone. A selection is refused on a file that stages whole (untracked, deleted, binary, a mode change) and in the `dw` and `ds` views. + Certain changes can't be displayed in that span, e.g. a change staged and then put back on disk, or a staged and then further modified hunk. Those hunks carry a `!` in the gutter and the winbar says how many exist in the current file. `dw` opens a view to show what changed since staging, where they show as hunks of their own. `ds` opens a view of **only** the files with staged changes, each diffed against the index, i.e. what is about to be committed, where `u` takes one back out. A hunk on a file's last line whose final newline differs between the two sides carries a `¬` in the gutter on the side that ends without one, since the two lines otherwise read the same. diff --git a/doc/differ.txt b/doc/differ.txt index a140b4e..02ee00b 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -302,6 +302,12 @@ and `u` stage and unstage entire rows/files (works on sections, directories and individual rows). `S` and `U` are available in both contexts as stage/unstage-all. +In visual mode, `s` and `u` stage and unstage only the selected lines. In +split-view, a selection takes whole rows, so a modified line moves with both +its halves. In stacked-view, the old and new lines are rows of their own, so +you can take one side alone. A selection is refused on a file that stages whole +(untracked, deleted, binary, a mode change) and in the `dw` and `ds` views. + Certain changes can’t be displayed in that span, e.g. a change staged and then put back on disk, or a staged and then further modified hunk. Those hunks carry a `!` in the gutter and the winbar says how many exist in the current From 44be67e5b1f813a71e1d2f06eada983240dc1281 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:36:20 +0100 Subject: [PATCH 5/6] refactor: fold the visual stage keys into the hunk help row --- lua/differ/view.lua | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 7b7f6b2..b26bc23 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -940,13 +940,15 @@ function View:show_help() end -- per-file, not session-wide: a source without `apply` would advertise keys that refuse if self:_can_stage_hunk() then - local unit = self:_whole_file() and "file" or "hunk" - rows[#rows + 1] = { pair(km.stage, km.unstage), "stage / unstage " .. unit } + local what = "stage / unstage hunk" + if self:_whole_file() then + what = "stage / unstage file" + elseif self:_can_stage_lines() then + what = "stage / unstage hunk (visual: selected lines)" + end + rows[#rows + 1] = { pair(km.stage, km.unstage), what } rows[#rows + 1] = { pair(km.stage_all, km.unstage_all), "stage / unstage all" } end - if self:_can_stage_lines() then - rows[#rows + 1] = { pair(km.stage, km.unstage), "stage / unstage selected lines (visual)" } - end if self.staging and self.staging.revert then rows[#rows + 1] = { fmt(km.discard), "revert hunk (confirm)" } end From 6f8f63b12611975a10e3c0c11f3a0f0812b05f05 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:52:25 +0100 Subject: [PATCH 6/6] docs: changelog for visual stage and unstage --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9354247..4e12163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `s` and `u` in visual mode stage and unstage only the selected lines of the diff + +## [0.2.5] — 2026-10-18 + +### Added + - `:Differ continue` finishes the merge, rebase, cherry-pick, revert or `am` in progress without leaving nvim: continue, edit the message in a buffer, skip or abort - The merge tool keeps or deletes a file changed on one side and deleted on the other - The merge tool resolves a binary conflict by taking ours or theirs