feat(git/cmd): make :G diff output navigable

This commit is contained in:
2026-05-07 23:18:17 +02:00
parent 93c9b6500a
commit c543f0a7ba
5 changed files with 291 additions and 48 deletions
+132 -9
View File
@@ -8,13 +8,89 @@ local M = {}
---@class ow.Git.Cmd.SplitHandler
---@field ft string
---@field needs_rev boolean?
---@field on_state? fun(state: ow.Git.Repo.BufState, r: ow.Git.Repo, args: string[])
---@param r ow.Git.Repo
---@param args string[] -- diff args including leading "diff"
---@return string left_ref
---@return string? right_ref -- nil means worktree
local function compute_diff_refs(r, args)
local cached = false
local positional = {} ---@type string[]
local saw_separator = false
for i = 2, #args do
local a = args[i]
if saw_separator then
break
elseif a == "--" then
saw_separator = true
elseif a == "--cached" or a == "--staged" then
cached = true
elseif a:sub(1, 1) ~= "-" then
table.insert(positional, a)
end
end
local function defaults()
if cached then
return "HEAD", ":"
end
return ":", nil
end
if #positional == 0 then
return defaults()
end
local first = positional[1] --[[@as string]]
if #positional == 1 then
local lhs, rhs = first:match("^(.-)%.%.%.(.+)$")
if lhs then
return (lhs ~= "" and lhs or "HEAD"), rhs
end
lhs, rhs = first:match("^(.-)%.%.(.+)$")
if lhs then
return (lhs ~= "" and lhs or "HEAD"),
(rhs ~= "" and rhs or "HEAD")
end
if r:rev_parse(first, true) then
if cached then
return first, ":"
end
return first, nil
end
return defaults()
end
local second = positional[2] --[[@as string]]
local first_ok = r:rev_parse(first, true) ~= nil
if first_ok and r:rev_parse(second, true) then
return first, second
end
if first_ok then
if cached then
return first, ":"
end
return first, nil
end
return defaults()
end
---@type table<string, ow.Git.Cmd.SplitHandler>
local SPLIT_HANDLERS = {
log = { ft = "git" },
diff = { ft = "git" },
diff = {
ft = "gitdiff",
on_state = function(state, r, args)
local left, right = compute_diff_refs(r, args)
state.left_ref = left
state.right_ref = right
end,
},
}
M._compute_diff_refs = compute_diff_refs
---@type string[]?
local cached_cmds
@@ -147,20 +223,57 @@ end
---@return integer buf
local function place_split(name)
local buf = vim.fn.bufnr("\\V" .. name)
if buf == -1 or not vim.api.nvim_buf_is_loaded(buf) then
buf = util.new_scratch()
pcall(vim.api.nvim_buf_set_name, buf, name)
if buf == -1 then
buf = util.new_scratch({ name = name, bufhidden = "hide" })
return buf
end
local win_id = vim.fn.bufwinid(buf)
if win_id ~= -1 then
vim.api.nvim_set_current_win(win_id)
if not vim.api.nvim_buf_is_loaded(buf) then
vim.fn.bufload(buf)
end
local win = vim.fn.bufwinid(buf)
if win ~= -1 then
vim.api.nvim_set_current_win(win)
else
util.place_buf(buf, nil)
end
return buf
end
---@param buf integer
local function clear_undo(buf)
local saved = vim.bo[buf].undolevels
vim.bo[buf].undolevels = -1
vim.bo[buf].modifiable = true
vim.api.nvim_buf_call(buf, function()
vim.cmd('silent! exe "normal! a \\<BS>\\<Esc>"')
end)
vim.bo[buf].modifiable = false
vim.bo[buf].undolevels = saved
end
---@param buf integer
local function attach_history_keys(buf)
local function bypass(fn)
return function()
vim.bo[buf].modifiable = true
pcall(fn)
vim.bo[buf].modifiable = false
end
end
vim.keymap.set(
"n",
"u",
bypass(vim.cmd.undo),
{ buffer = buf, desc = "Undo" }
)
vim.keymap.set(
"n",
"<C-r>",
bypass(vim.cmd.redo),
{ buffer = buf, desc = "Redo" }
)
end
---@param r ow.Git.Repo
---@param args string[]
---@param conf ow.Git.Cmd.SplitHandler
@@ -173,10 +286,10 @@ local function run_in_split(r, args, conf)
if not stdout then
return
end
local name = "[git " .. table.concat(args, " ") .. "]"
local buf = place_split(name)
local buf = place_split("[Git " .. table.concat(args, " ") .. "]")
repo.bind(buf, r)
object.attach_dispatch(buf)
attach_history_keys(buf)
local state = r:state(buf) --[[@as -nil]]
state.sha = nil
state.parent_sha = nil
@@ -188,8 +301,18 @@ local function run_in_split(r, args, conf)
state.parent_sha = r:rev_parse(user_rev .. "^", true)
end
end
if conf.on_state then
conf.on_state(state, r, args)
end
vim.bo[buf].filetype = conf.ft
-- Force a new undo block so each rerun is its own undo step.
vim.bo[buf].undolevels = vim.bo[buf].undolevels
local first_run = not state.initialized
util.set_buf_lines(buf, 0, -1, util.split_lines(stdout))
if first_run then
clear_undo(buf)
state.initialized = true
end
end,
})
end