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
7 changes: 7 additions & 0 deletions doc/diffview.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2087,6 +2087,10 @@ toggle_stage_entry *diffview-actions-toggle_stage_e
Stage / unstage the subject. If files are selected (via
`toggle_select_entry`), operates on all selected files.

On `jj` (no staging index), a conflict view instead writes the
current entry's MERGED buffer and advances to the next conflict;
pair with `auto_close_on_empty` to close after the last resolve.

unstage_all *diffview-actions-unstage_all*
Contexts: `diff_view`, `file_panel`

Expand Down Expand Up @@ -2429,6 +2433,9 @@ C Clear all file selections.
*diffview-maps-toggle_stage_entry*
- Stage/unstage the selected file entry. If files are
selected, operates on all selected files as a batch.
On a `jj` conflict, writes the MERGED buffer to disk
and advances to the next entry instead (see
|diffview-actions-toggle_stage_entry|).

*diffview-maps-stage_all*
S Stage all entries.
Expand Down
4 changes: 2 additions & 2 deletions doc/diffview_defaults.txt
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,8 @@ DEFAULT CONFIG *diffview.defaults*
{ "n", "<2-LeftMouse>", actions.select_entry, { desc = "Open the diff for the selected entry" } },
{ { "n", "x" }, "w", actions.toggle_select_entry, { desc = "Toggle file selection for multi-file operations" } },
{ "n", "C", actions.clear_select_entries, { desc = "Clear all file selections" } },
{ "n", "-", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry" } },
{ "n", "s", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry" } },
{ "n", "-", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry (jj: save & advance)" } },
{ "n", "s", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry (jj: save & advance)" } },
{ "n", "S", actions.stage_all, { desc = "Stage all entries" } },
{ "n", "U", actions.unstage_all, { desc = "Unstage all entries" } },
{ "n", "X", actions.restore_entry, { desc = "Restore entry to the state on the left side" } },
Expand Down
30 changes: 24 additions & 6 deletions lua/diffview/actions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ M.compat = {}
---suppress entries that would be no-ops in the current view. Weak keys let
---closures returned by action factories be collected once no keymap references
---them anymore.
---@alias diffview.ActionTag "merge_only" | "working_tree_only"
---@alias diffview.ActionTag "merge_only" | "working_tree_only" | "stage_or_resolve"
---@type table<function, diffview.ActionTag>
local action_meta = setmetatable({}, { __mode = "k" })

Expand Down Expand Up @@ -153,10 +153,10 @@ function M._is_applicable(fn, view)
return view ~= nil and (view --[[@as DiffView]]).merge_ctx ~= nil
end
if t == "working_tree_only" then
-- Index-vs-worktree actions (staging, etc.) are only meaningful when the
-- diff compares STAGE against LOCAL. `left`/`right` exist on `DiffView`
-- and `FileHistoryView`; in the latter, `left.type` is `COMMIT`, so the
-- predicate correctly evaluates to false there.
-- Strict index-vs-worktree actions (`stage_all`, `unstage_all`) are only
-- meaningful when the diff compares STAGE against LOCAL. `left`/`right`
-- exist on `DiffView` and `FileHistoryView`; in the latter, `left.type`
-- is `COMMIT`, so the predicate correctly evaluates to false there.
if view == nil then
return false
end
Expand All @@ -166,6 +166,24 @@ function M._is_applicable(fn, view)
and v.left.type == RevType.STAGE
and v.right.type == RevType.LOCAL
end
if t == "stage_or_resolve" then
-- `toggle_stage_entry` covers index-based staging (git/hg) and
-- content-based resolution (index-less adapters, e.g., jj). Split by
-- adapter capability, not just rev shape, so a staging adapter in a
-- mid-merge non-STAGE view (e.g., `:DiffviewOpen HEAD` during a git
-- merge) doesn't get the resolve treatment.
if view == nil then
return false
end
local v = view --[[@as DiffView]]
if v.left == nil or v.right == nil then
return false
end
if v.adapter:has_staging() then
return v.left.type == RevType.STAGE and v.right.type == RevType.LOCAL
end
return v.right.type == RevType.LOCAL and v.merge_ctx ~= nil
end
return true
end

Expand Down Expand Up @@ -1261,7 +1279,7 @@ local action_names = {
---stub site) because the stubs are generated in the loop below.
---@type table<string, diffview.ActionTag>
local action_tags = {
toggle_stage_entry = "working_tree_only",
toggle_stage_entry = "stage_or_resolve",
stage_all = "working_tree_only",
unstage_all = "working_tree_only",
}
Expand Down
4 changes: 2 additions & 2 deletions lua/diffview/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -716,8 +716,8 @@ M.defaults = {
file_panel = utils.vec_join(common_panel_keymaps, common_nav_keymaps, {
{ { "n", "x" }, "w", actions.toggle_select_entry, { desc = "Toggle file selection for multi-file operations" } },
{ "n", "C", actions.clear_select_entries, { desc = "Clear all file selections" } },
{ "n", "-", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry" } },
{ "n", "s", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry" } },
{ "n", "-", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry (jj: save & advance)" } },
{ "n", "s", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry (jj: save & advance)" } },
{ "n", "S", actions.stage_all, { desc = "Stage all entries" } },
{ "n", "U", actions.unstage_all, { desc = "Unstage all entries" } },
{ "n", "X", actions.restore_entry, { desc = "Restore entry to the state on the left side" } },
Expand Down
145 changes: 138 additions & 7 deletions lua/diffview/scene/views/diff/listeners.lua
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,21 @@ return function(view)
-- guarded close aborts (dirty stage buffer); cleared when the retry either
-- succeeds or no longer applies (working/conflicting non-empty).
local auto_close_pending = false

---Run the `auto_close_on_empty` policy. Closes the view when there are no
---working/conflicting entries left and stage buffers are clean. If the
---guarded close aborts, set the retry flag so the next stage save (via
---`buf_write_post`) re-evaluates.
-- Latched once this view surfaces a conflict, so index-less adapters (where
-- a resolved file lands in `working` rather than leaving the view) only
-- auto-close after a merge was actually resolved here.
local had_conflicts = false

---Run the `auto_close_on_empty` policy. Closes the view when the
---`conflicting` bucket is empty AND stage buffers are clean AND either:
--- (a) staging adapters (git/hg): the `working` bucket is also empty,
--- i.e., every change has been staged; or
--- (b) index-less adapters (jj): the view previously surfaced
--- conflicts, i.e., a merge was actually resolved here (a resolved
--- file stays in `working` as the resolution artifact, so an
--- empty `working` bucket cannot be the gate).
---If the guarded close aborts, set the retry flag so the next stage
---save (via `buf_write_post`) re-evaluates.
---
---When `silent` is true, the dirty-stage gate is pre-checked and the close
---call is skipped if it would fail. The BufWritePost retry path uses this
Expand All @@ -35,10 +45,24 @@ return function(view)
auto_close_pending = false
return
end
if #view.files.working ~= 0 or #view.files.conflicting ~= 0 then
if #view.files.conflicting ~= 0 then
auto_close_pending = false
return
end
-- See docstring: staging adapters gate on `working` empty; index-less
-- adapters gate on `had_conflicts` so an unrelated diff view doesn't
-- auto-close on the first save.
if view.adapter:has_staging() then
if #view.files.working ~= 0 then
auto_close_pending = false
return
end
else
if not had_conflicts then
auto_close_pending = false
return
end
end
if silent and #view:_modified_stage_paths() > 0 then
auto_close_pending = true
return
Expand Down Expand Up @@ -145,6 +169,10 @@ return function(view)
view.initialized = true
-- File entries may be replaced on update; prune stale selections.
view.panel:prune_selections()
-- Latch for the index-less branch of `maybe_auto_close`.
if #view.files.conflicting > 0 then
had_conflicts = true
end
end,
close = function()
if view.panel:is_focused() then
Expand Down Expand Up @@ -322,7 +350,110 @@ return function(view)
end
end,
toggle_stage_entry = function()
if not (view.left.type == RevType.STAGE and view.right.type == RevType.LOCAL) then
local right_is_local = view.right.type == RevType.LOCAL

-- Index-less adapters (jj): resolve by content instead of by staging.
-- Write the current entry's MERGED buffer to disk and advance; the
-- refresh observes it leave `conflicting` once the markers are gone,
-- and `auto_close_on_empty` handles the final close.
if not view.adapter:has_staging() then
if not (right_is_local and view.merge_ctx ~= nil) then
return
end
-- Use `cur_entry`, not `infer_cur_file`: the main window's MERGED
-- buffer belongs to the displayed entry, and a stale panel cursor
-- must not let us save one path while advancing from another.
local item = view.cur_entry
if not item or item.kind ~= "conflicting" then
return
end
-- Panel cursor on a different entry: no buffer for it is displayed,
-- so warn instead of silently writing `cur_entry`'s MERGED buffer.
if view.panel:is_focused() then
local hovered = view.panel:get_item_at_cursor()
if hovered and hovered ~= item then
utils.warn("Open the file to resolve its conflicts.")
return
end
end

local main = view.cur_layout and view.cur_layout:get_main_win()
if main and main:is_valid() and main.file and main.file:is_valid() then
local bufnr = main.file.bufnr
if bufnr and vim.bo[bufnr].modified then
api.nvim_buf_call(bufnr, function()
vim.cmd("silent update")
end)
end
end

-- Defer advance/close until after the refresh: keying off pre-refresh
-- state (like the git branch's `next_file`) would race with the close
-- policy when the resolved file was the last conflict.
local saved_path = item.path
-- Record the successor before refresh so mid-list resolution advances
-- downward rather than snapping to `conflicting[1]`.
local next_after_path
do
local seen_self = false
for _, e in ipairs(view.files.conflicting) do
if seen_self then
next_after_path = e.path
break
end
if e.path == saved_path then
seen_self = true
end
end
end
view:update_files(
nil,
vim.schedule_wrap(function()
local still_conflicting = false
for _, e in ipairs(view.files.conflicting) do
if e.path == saved_path then
still_conflicting = true
break
end
end
if not still_conflicting then
-- Prefer the pre-refresh successor; fall back to `conflicting[1]`
-- if it was resolved out-of-band or the resolved file was last.
local next_entry
if next_after_path then
for _, e in ipairs(view.files.conflicting) do
if e.path == next_after_path then
next_entry = e
break
end
end
end
next_entry = next_entry or view.files.conflicting[1]
if next_entry and view.cur_entry ~= next_entry then
view:set_file(next_entry, false, true)
end
end
view.panel:highlight_cur_file()
-- Wait for any in-flight `set_file` (`update_files` may schedule
-- one) to finish before letting `maybe_auto_close` run: tearing
-- down the layout mid-flight crashes `sync_scroll` on the freed
-- window ids.
local function close_when_idle()
local in_flight = view:set_file_in_flight()
if in_flight and not in_flight:is_done() then
vim.defer_fn(close_when_idle, 20)
return
end
maybe_auto_close()
end
close_when_idle()
end)
)
view.emitter:emit(EventName.FILES_STAGED, view)
return
end

if not (view.left.type == RevType.STAGE and right_is_local) then
return
end

Expand Down
8 changes: 8 additions & 0 deletions lua/diffview/scene/views/standard/standard_view.lua
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,14 @@ function StandardView:_set_file(file)
return self._set_file_in_flight
end

---The currently-running `_set_file` worker, or `nil` if none is outstanding.
---Exposed so external policy code (e.g., `maybe_auto_close`) can defer teardown
---until a queued file swap has finished, without reaching into `_set_file_in_flight`.
---@return Future?
function StandardView:set_file_in_flight()
return self._set_file_in_flight
end

---@param self StandardView
StandardView._drain_set_file_pending = async.void(function(self)
while self._set_file_pending do
Expand Down
Loading
Loading