feat(git): ambient progress for long-running :G subcommands
This commit is contained in:
+233
-40
@@ -1,18 +1,12 @@
|
||||
local commit = require("git.commit")
|
||||
local history = require("git.history")
|
||||
local object = require("git.object")
|
||||
local repo = require("git.repo")
|
||||
local util = require("git.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
---@class ow.Git.Cmd.SplitHandler
|
||||
---@field ft string
|
||||
|
||||
---@type table<string, ow.Git.Cmd.SplitHandler>
|
||||
local SPLIT_HANDLERS = {
|
||||
log = { ft = "git" },
|
||||
diff = { ft = "git" },
|
||||
}
|
||||
---@alias ow.Git.Cmd.Run fun(r: ow.Git.Repo, args: string[], bang: boolean)
|
||||
|
||||
---@type string[]?
|
||||
local cached_cmds
|
||||
@@ -182,8 +176,8 @@ end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param args string[]
|
||||
---@param conf ow.Git.Cmd.SplitHandler
|
||||
local function run_in_split(r, args, conf)
|
||||
---@param ft string
|
||||
local function run_in_split(r, args, ft)
|
||||
util.git(args, {
|
||||
cwd = r.worktree,
|
||||
on_exit = function(result)
|
||||
@@ -201,7 +195,7 @@ local function run_in_split(r, args, conf)
|
||||
object.attach_dispatch(buf)
|
||||
attach_history_keys(buf)
|
||||
local state = r:state(buf) --[[@as -nil]]
|
||||
vim.bo[buf].filetype = conf.ft
|
||||
vim.bo[buf].filetype = 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
|
||||
@@ -217,34 +211,230 @@ end
|
||||
---@param r ow.Git.Repo
|
||||
---@param args string[]
|
||||
local function run_to_messages(r, args)
|
||||
util.git(args, {
|
||||
local cmd = { "git" }
|
||||
vim.list_extend(cmd, args)
|
||||
local result = vim.system(cmd, {
|
||||
cwd = r.worktree,
|
||||
on_exit = function(result)
|
||||
local out = vim.trim(result.stdout or "")
|
||||
local err = vim.trim(result.stderr or "")
|
||||
local chunks = {}
|
||||
if out ~= "" then
|
||||
table.insert(chunks, { out })
|
||||
end
|
||||
if err ~= "" then
|
||||
if #chunks > 0 then
|
||||
table.insert(chunks, { "\n" })
|
||||
end
|
||||
table.insert(chunks, { err, "ErrorMsg" })
|
||||
end
|
||||
if #chunks == 0 and result.code ~= 0 then
|
||||
table.insert(
|
||||
chunks,
|
||||
{ "git exited " .. tostring(result.code), "ErrorMsg" }
|
||||
)
|
||||
end
|
||||
if #chunks > 0 then
|
||||
vim.api.nvim_echo(chunks, true, {})
|
||||
end
|
||||
text = true,
|
||||
env = util.DEFAULT_GIT_ENV,
|
||||
}):wait()
|
||||
local out = vim.trim(result.stdout or "")
|
||||
local err = vim.trim(result.stderr or "")
|
||||
local failed = result.code ~= 0
|
||||
|
||||
local chunks = {}
|
||||
if out ~= "" then
|
||||
table.insert(chunks, { out })
|
||||
end
|
||||
if err ~= "" then
|
||||
if #chunks > 0 then
|
||||
table.insert(chunks, { "\n" })
|
||||
end
|
||||
table.insert(chunks, { err, failed and "ErrorMsg" or nil })
|
||||
end
|
||||
if #chunks == 0 and failed then
|
||||
table.insert(
|
||||
chunks,
|
||||
{ "git exited " .. tostring(result.code), "ErrorMsg" }
|
||||
)
|
||||
end
|
||||
if #chunks > 0 then
|
||||
vim.api.nvim_echo(chunks, true, {})
|
||||
end
|
||||
end
|
||||
|
||||
---@return integer
|
||||
local function find_or_create_preview_win()
|
||||
for _, w in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||
if vim.wo[w].previewwindow then
|
||||
return w
|
||||
end
|
||||
end
|
||||
vim.cmd(("botright %dnew"):format(vim.o.previewheight))
|
||||
local w = vim.api.nvim_get_current_win()
|
||||
vim.wo[w].previewwindow = true
|
||||
return w
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param args string[]
|
||||
local function run_in_preview(r, args)
|
||||
local prev_win = vim.api.nvim_get_current_win()
|
||||
local pwin = find_or_create_preview_win()
|
||||
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[buf].bufhidden = "wipe"
|
||||
vim.api.nvim_win_set_buf(pwin, buf)
|
||||
|
||||
vim.api.nvim_set_current_win(pwin)
|
||||
local cmd = { "git" }
|
||||
vim.list_extend(cmd, args)
|
||||
local job = vim.fn.jobstart(cmd, {
|
||||
cwd = r.worktree,
|
||||
term = true,
|
||||
})
|
||||
vim.api.nvim_set_current_win(prev_win)
|
||||
|
||||
if job <= 0 then
|
||||
util.error("failed to start git job")
|
||||
return
|
||||
end
|
||||
|
||||
vim.api.nvim_create_autocmd("BufWipeout", {
|
||||
buffer = buf,
|
||||
once = true,
|
||||
callback = function()
|
||||
pcall(vim.fn.jobstop, job)
|
||||
end,
|
||||
})
|
||||
|
||||
vim.keymap.set("n", "q", "<cmd>pclose<cr>", {
|
||||
buffer = buf,
|
||||
nowait = true,
|
||||
desc = "Close preview",
|
||||
})
|
||||
vim.keymap.set("n", "<C-c>", function()
|
||||
pcall(vim.fn.jobstop, job)
|
||||
end, { buffer = buf, nowait = true, desc = "Cancel git job" })
|
||||
end
|
||||
|
||||
---@param ft string
|
||||
---@return ow.Git.Cmd.Run
|
||||
local function in_split(ft)
|
||||
return function(r, args, _bang)
|
||||
run_in_split(r, args, ft)
|
||||
end
|
||||
end
|
||||
|
||||
---@param line string
|
||||
---@return string
|
||||
local function clean_progress_line(line)
|
||||
if not (line:find("\27", 1, true) or line:find("\r", 1, true)) then
|
||||
return line
|
||||
end
|
||||
line = line:gsub("\27%[[%d;?]*[%a]", "")
|
||||
local parts = vim.split(line, "\r", { plain = true })
|
||||
for i = #parts, 1, -1 do
|
||||
if parts[i] ~= "" then
|
||||
return parts[i]
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param args string[]
|
||||
local function run_streaming(r, args)
|
||||
local title = "git " .. (args[1] or "")
|
||||
local id = "git." .. tostring(vim.uv.hrtime())
|
||||
---@type string[]
|
||||
local accum = {}
|
||||
local partial = ""
|
||||
local last_progress = ""
|
||||
|
||||
---@param text string
|
||||
---@param status "running"|"success"|"failed"
|
||||
local function emit_progress(text, status)
|
||||
vim.api.nvim_echo({ { text } }, false, {
|
||||
id = id,
|
||||
kind = "progress",
|
||||
status = status,
|
||||
title = title,
|
||||
source = "git",
|
||||
})
|
||||
end
|
||||
|
||||
local function on_data(_, data, _)
|
||||
if not data or #data == 0 then
|
||||
return
|
||||
end
|
||||
if #data == 1 and data[1] == "" then
|
||||
return
|
||||
end
|
||||
partial = partial .. data[1]
|
||||
local prev = last_progress
|
||||
for i = 2, #data do
|
||||
local cleaned = clean_progress_line(partial)
|
||||
if cleaned ~= "" then
|
||||
table.insert(accum, cleaned)
|
||||
last_progress = cleaned
|
||||
end
|
||||
partial = data[i]
|
||||
end
|
||||
if partial ~= "" then
|
||||
local cleaned = clean_progress_line(partial)
|
||||
if cleaned ~= "" then
|
||||
last_progress = cleaned
|
||||
end
|
||||
end
|
||||
if last_progress ~= prev then
|
||||
emit_progress(last_progress, "running")
|
||||
end
|
||||
end
|
||||
|
||||
local function on_exit(_, code)
|
||||
if partial ~= "" then
|
||||
local cleaned = clean_progress_line(partial)
|
||||
if cleaned ~= "" then
|
||||
table.insert(accum, cleaned)
|
||||
last_progress = cleaned
|
||||
end
|
||||
partial = ""
|
||||
end
|
||||
if code == 0 then
|
||||
emit_progress(
|
||||
last_progress ~= "" and last_progress or "done",
|
||||
"success"
|
||||
)
|
||||
history.append(args, accum)
|
||||
else
|
||||
emit_progress(("exit %d"):format(code), "failed")
|
||||
local body = #accum > 0 and table.concat(accum, "\n")
|
||||
or ("%s failed: exit %d"):format(title, code)
|
||||
vim.api.nvim_echo({ { body, "ErrorMsg" } }, true, {})
|
||||
history.append(args, accum)
|
||||
end
|
||||
end
|
||||
|
||||
local cmd = { "git" }
|
||||
vim.list_extend(cmd, args)
|
||||
local job = vim.fn.jobstart(cmd, {
|
||||
cwd = r.worktree,
|
||||
pty = true,
|
||||
env = util.DEFAULT_GIT_ENV,
|
||||
on_stdout = on_data,
|
||||
on_stderr = on_data,
|
||||
on_exit = on_exit,
|
||||
})
|
||||
if job <= 0 then
|
||||
util.error("failed to start git job")
|
||||
end
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param args string[]
|
||||
---@param bang boolean
|
||||
local function streaming_dispatch(r, args, bang)
|
||||
if bang then
|
||||
run_in_preview(r, args)
|
||||
else
|
||||
run_streaming(r, args)
|
||||
end
|
||||
end
|
||||
|
||||
---@type table<string, ow.Git.Cmd.Run>
|
||||
local HANDLERS = {
|
||||
log = in_split("git"),
|
||||
diff = in_split("git"),
|
||||
push = streaming_dispatch,
|
||||
fetch = streaming_dispatch,
|
||||
pull = streaming_dispatch,
|
||||
clone = streaming_dispatch,
|
||||
am = streaming_dispatch,
|
||||
["cherry-pick"] = streaming_dispatch,
|
||||
revert = streaming_dispatch,
|
||||
}
|
||||
|
||||
---@param args string[]
|
||||
---@return boolean
|
||||
local function has_message(args)
|
||||
@@ -262,13 +452,16 @@ local function has_message(args)
|
||||
end
|
||||
|
||||
---@param args string[]
|
||||
function M.run(args)
|
||||
---@param opts { bang: boolean? }?
|
||||
function M.run(args, opts)
|
||||
local r = repo.resolve()
|
||||
if not r then
|
||||
util.error("not in a git repository")
|
||||
return
|
||||
end
|
||||
|
||||
local bang = opts and opts.bang or false
|
||||
|
||||
local sub = args[1]
|
||||
if sub == "commit" and not has_message(args) then
|
||||
commit.commit({ args = vim.list_slice(args, 2) })
|
||||
@@ -280,7 +473,7 @@ function M.run(args)
|
||||
object.open(r, args[2])
|
||||
return
|
||||
end
|
||||
run_in_split(r, args, { ft = "git" })
|
||||
run_in_split(r, args, "git")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -289,13 +482,13 @@ function M.run(args)
|
||||
object.open(r, args[3])
|
||||
return
|
||||
end
|
||||
run_in_split(r, args, { ft = "git" })
|
||||
run_in_split(r, args, "git")
|
||||
return
|
||||
end
|
||||
|
||||
local conf = sub and SPLIT_HANDLERS[sub]
|
||||
if conf then
|
||||
run_in_split(r, args, conf)
|
||||
local handler = sub and HANDLERS[sub]
|
||||
if handler then
|
||||
handler(r, args, bang)
|
||||
else
|
||||
run_to_messages(r, args)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user