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
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

- `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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ Sources with worktree on the new side (`:Differ`, `:Differ <rev>`, `:Differ <a>.

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.
Expand Down
6 changes: 6 additions & 0 deletions doc/differ.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions lua/differ/git/staging.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<differ.model.SelectRefusal, { stage: string, unstage: string }>
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
Expand Down Expand Up @@ -275,6 +292,40 @@ 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
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
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
Expand Down
110 changes: 110 additions & 0 deletions lua/differ/model/stage.lua
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,116 @@ function M.next_index(union, cached, unstaged, hunk, take)
return text
end

---@class differ.model.Selection
---@field old table<integer, boolean> -- union old lnums
---@field new table<integer, boolean> -- 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<integer, boolean>
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
Expand Down
76 changes: 74 additions & 2 deletions lua/differ/view.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -836,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")
Expand Down Expand Up @@ -933,8 +940,13 @@ 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.staging and self.staging.revert then
Expand Down Expand Up @@ -1184,6 +1196,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
Expand Down Expand Up @@ -1332,6 +1349,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("<Esc>", 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
Expand Down
Loading
Loading