refactor(git): rework module around clearer Status and Repo split
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
local Revision = require("git.revision")
|
||||
local diff = require("git.diff")
|
||||
local object = require("git.object")
|
||||
local repo = require("git.repo")
|
||||
local util = require("git.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
---@type ow.Git.Status.EntryKind[]
|
||||
local KINDS = { "untracked", "unstaged", "staged", "unmerged" }
|
||||
local WINDOW_WIDTH = 50
|
||||
|
||||
---@class ow.Git.StatusView.State
|
||||
---@field repo ow.Git.Repo
|
||||
---@field lines table<integer, ow.Git.Status.Entry>
|
||||
---@field win integer?
|
||||
---@field invocation_win integer?
|
||||
---@field diff_left_win integer?
|
||||
---@field diff_right_win integer?
|
||||
---@field unsubscribe fun()?
|
||||
---@field last_shown_key string?
|
||||
---@field last_render_key string?
|
||||
|
||||
---@type table<integer, ow.Git.StatusView.State>
|
||||
local state = {}
|
||||
|
||||
local group =
|
||||
vim.api.nvim_create_augroup("ow.git.status_win", { clear = false })
|
||||
local ns = vim.api.nvim_create_namespace("ow.git.status_win")
|
||||
|
||||
---@return integer? win
|
||||
---@return integer? bufnr
|
||||
local function find_view()
|
||||
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||
local buf = vim.api.nvim_win_get_buf(win)
|
||||
if vim.bo[buf].filetype == "gitstatus" then
|
||||
return win, buf
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param s ow.Git.StatusView.State
|
||||
---@return integer?
|
||||
local function win_for(s)
|
||||
local win = s.win
|
||||
if win and vim.api.nvim_win_is_valid(win) then
|
||||
return win
|
||||
end
|
||||
win = find_view()
|
||||
s.win = win
|
||||
return win
|
||||
end
|
||||
|
||||
---@param entry ow.Git.Status.Entry
|
||||
---@return string line
|
||||
---@return string hl_group
|
||||
---@return integer hl_len
|
||||
local function format_entry(entry)
|
||||
local label = entry.orig and (entry.orig .. " -> " .. entry.path)
|
||||
or entry.path
|
||||
return string.format(" %s %s", entry.char, label), entry.hl, #entry.char
|
||||
end
|
||||
|
||||
---@param kind ow.Git.Status.EntryKind
|
||||
---@return string
|
||||
local function display_name(kind)
|
||||
return (kind:gsub("^%l", string.upper))
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param status ow.Git.Status
|
||||
local function render(bufnr, status)
|
||||
local branch = status.branch
|
||||
local lines = { "Head: " .. (branch.head or "?") }
|
||||
if branch.upstream then
|
||||
local push = "Push: " .. branch.upstream
|
||||
if branch.ahead > 0 then
|
||||
push = push .. " +" .. branch.ahead
|
||||
end
|
||||
if branch.behind > 0 then
|
||||
push = push .. " -" .. branch.behind
|
||||
end
|
||||
table.insert(lines, push)
|
||||
end
|
||||
table.insert(lines, "")
|
||||
|
||||
local meta = {}
|
||||
local marks = {}
|
||||
for _, kind in ipairs(KINDS) do
|
||||
local entries = status:by_kind(kind)
|
||||
if #entries > 0 then
|
||||
table.insert(
|
||||
lines,
|
||||
string.format("%s (%d)", display_name(kind), #entries)
|
||||
)
|
||||
for _, entry in ipairs(entries) do
|
||||
local line, hl, hl_len = format_entry(entry)
|
||||
table.insert(lines, line)
|
||||
meta[#lines] = entry
|
||||
table.insert(marks, {
|
||||
row = #lines - 1,
|
||||
col = 2,
|
||||
end_col = 2 + hl_len,
|
||||
hl = hl,
|
||||
})
|
||||
end
|
||||
table.insert(lines, "")
|
||||
end
|
||||
end
|
||||
|
||||
vim.bo[bufnr].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
|
||||
vim.bo[bufnr].modifiable = false
|
||||
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
|
||||
for _, m in ipairs(marks) do
|
||||
vim.api.nvim_buf_set_extmark(bufnr, ns, m.row, m.col, {
|
||||
end_col = m.end_col,
|
||||
hl_group = m.hl,
|
||||
})
|
||||
end
|
||||
state[bufnr].lines = meta
|
||||
end
|
||||
|
||||
---@param status ow.Git.Status
|
||||
---@return string
|
||||
local function fingerprint(status)
|
||||
local branch = status.branch
|
||||
local parts = {
|
||||
branch.head or "",
|
||||
branch.upstream or "",
|
||||
tostring(branch.ahead),
|
||||
tostring(branch.behind),
|
||||
}
|
||||
for _, kind in ipairs(KINDS) do
|
||||
for _, e in ipairs(status:by_kind(kind)) do
|
||||
table.insert(
|
||||
parts,
|
||||
e.kind
|
||||
.. ":"
|
||||
.. e.path
|
||||
.. ":"
|
||||
.. (e.orig or "")
|
||||
.. ":"
|
||||
.. e.char
|
||||
)
|
||||
end
|
||||
end
|
||||
return table.concat(parts, "\0")
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
local function refresh(bufnr)
|
||||
local s = state[bufnr]
|
||||
if not s then
|
||||
return
|
||||
end
|
||||
|
||||
local saved_path
|
||||
local status_win = win_for(s)
|
||||
if status_win then
|
||||
local lnum = vim.api.nvim_win_get_cursor(status_win)[1]
|
||||
local entry = s.lines[lnum]
|
||||
if entry then
|
||||
saved_path = entry.path
|
||||
end
|
||||
end
|
||||
|
||||
if not vim.api.nvim_buf_is_valid(bufnr) then
|
||||
return
|
||||
end
|
||||
local status = s.repo.status
|
||||
s.last_shown_key = nil
|
||||
local fp = fingerprint(status)
|
||||
if fp == s.last_render_key then
|
||||
return
|
||||
end
|
||||
s.last_render_key = fp
|
||||
render(bufnr, status)
|
||||
if not saved_path then
|
||||
return
|
||||
end
|
||||
for lnum, entry in pairs(s.lines) do
|
||||
if entry.path == saved_path then
|
||||
local win = win_for(s)
|
||||
if win then
|
||||
pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 })
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@return ow.Git.StatusView.State?
|
||||
---@return ow.Git.Status.Entry?
|
||||
local function current_entry(bufnr)
|
||||
local s = state[bufnr]
|
||||
if not s then
|
||||
return nil, nil
|
||||
end
|
||||
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
return s, s.lines[lnum]
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param path string
|
||||
---@return ow.Git.Diff.Side
|
||||
local function head_pane(r, path)
|
||||
local rev = Revision.new({ base = "HEAD", path = path })
|
||||
return {
|
||||
buf = object.buf_for(r, rev),
|
||||
name = object.format_uri(rev),
|
||||
}
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param path string
|
||||
---@return ow.Git.Diff.Side
|
||||
local function worktree_pane(r, path)
|
||||
local buf = vim.fn.bufadd(vim.fs.joinpath(r.worktree, path))
|
||||
vim.fn.bufload(buf)
|
||||
return { buf = buf, name = nil }
|
||||
end
|
||||
|
||||
---@param s ow.Git.StatusView.State
|
||||
---@param entry ow.Git.Status.Entry
|
||||
---@return ow.Git.Diff.Side
|
||||
local function index_pane(s, entry)
|
||||
local rev = Revision.new({ stage = 0, path = entry.path })
|
||||
return {
|
||||
buf = object.buf_for(s.repo, rev),
|
||||
name = object.format_uri(rev),
|
||||
}
|
||||
end
|
||||
|
||||
---@param s ow.Git.StatusView.State
|
||||
---@param entry ow.Git.Status.Entry
|
||||
---@return ow.Git.Diff.Side?
|
||||
local function older_pane(s, entry)
|
||||
if entry.kind == "staged" then
|
||||
if entry.char == "A" then
|
||||
return nil
|
||||
end
|
||||
return head_pane(s.repo, entry.orig or entry.path)
|
||||
end
|
||||
if entry.kind == "unstaged" then
|
||||
return index_pane(s, entry)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param s ow.Git.StatusView.State
|
||||
---@param entry ow.Git.Status.Entry
|
||||
---@return ow.Git.Diff.Side?
|
||||
local function newer_pane(s, entry)
|
||||
if entry.kind == "staged" then
|
||||
if entry.char == "D" then
|
||||
return nil
|
||||
end
|
||||
return index_pane(s, entry)
|
||||
end
|
||||
if entry.kind == "unstaged" then
|
||||
if entry.char == "D" then
|
||||
return nil
|
||||
end
|
||||
return worktree_pane(s.repo, entry.path)
|
||||
end
|
||||
if entry.kind == "untracked" then
|
||||
return worktree_pane(s.repo, entry.path)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param win integer
|
||||
local function reset_diff_win(win)
|
||||
vim.api.nvim_win_call(win, function()
|
||||
vim.cmd(
|
||||
"setlocal winfixwidth< number< relativenumber< signcolumn< wrap< cursorline<"
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
---@param s ow.Git.StatusView.State
|
||||
---@return integer?
|
||||
local function invocation_win_for(s)
|
||||
local win = s.invocation_win
|
||||
if not win or not vim.api.nvim_win_is_valid(win) then
|
||||
return nil
|
||||
end
|
||||
if win == s.win then
|
||||
return nil
|
||||
end
|
||||
if
|
||||
vim.api.nvim_win_get_tabpage(win)
|
||||
~= vim.api.nvim_get_current_tabpage()
|
||||
then
|
||||
return nil
|
||||
end
|
||||
return win
|
||||
end
|
||||
|
||||
---@param s ow.Git.StatusView.State
|
||||
---@param status_win integer
|
||||
---@return integer? left
|
||||
---@return integer? right
|
||||
local function adopt_diff_wins(s, status_win)
|
||||
local left = s.diff_left_win
|
||||
local right = s.diff_right_win
|
||||
if left and not vim.api.nvim_win_is_valid(left) then
|
||||
left = nil
|
||||
end
|
||||
if right and not vim.api.nvim_win_is_valid(right) then
|
||||
right = nil
|
||||
end
|
||||
if left and right then
|
||||
return left, right
|
||||
end
|
||||
for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do
|
||||
if win ~= status_win then
|
||||
local role = vim.w[win].git_diff_role
|
||||
if role == "left" and not left then
|
||||
left = win
|
||||
elseif role == "right" and not right then
|
||||
right = win
|
||||
end
|
||||
end
|
||||
end
|
||||
return left, right
|
||||
end
|
||||
|
||||
---@param entry ow.Git.Status.Entry
|
||||
---@return string
|
||||
local function entry_key(entry)
|
||||
return entry.kind .. "|" .. entry.path .. "|" .. (entry.orig or "")
|
||||
end
|
||||
|
||||
---@param target_win integer
|
||||
---@param dir "left"|"right"
|
||||
---@return integer
|
||||
local function vsplit_at(target_win, dir)
|
||||
local win = vim.api.nvim_open_win(
|
||||
vim.api.nvim_win_get_buf(target_win),
|
||||
true,
|
||||
{ split = dir, win = target_win }
|
||||
)
|
||||
vim.cmd.clearjumps()
|
||||
return win
|
||||
end
|
||||
|
||||
---@param s ow.Git.StatusView.State
|
||||
---@param status_win integer
|
||||
---@param right_win integer?
|
||||
---@return integer
|
||||
local function ensure_right_win(s, status_win, right_win)
|
||||
if right_win then
|
||||
return right_win
|
||||
end
|
||||
local target = invocation_win_for(s)
|
||||
if target then
|
||||
right_win = target
|
||||
else
|
||||
right_win = vsplit_at(status_win, "right")
|
||||
vim.api.nvim_win_set_width(status_win, WINDOW_WIDTH)
|
||||
end
|
||||
reset_diff_win(right_win)
|
||||
return right_win
|
||||
end
|
||||
|
||||
---@param s ow.Git.StatusView.State
|
||||
---@param entry ow.Git.Status.Entry
|
||||
---@param focus_left boolean
|
||||
local function view_entry(s, entry, focus_left)
|
||||
local status_win = win_for(s)
|
||||
if not status_win then
|
||||
return
|
||||
end
|
||||
|
||||
local left = older_pane(s, entry)
|
||||
local right = newer_pane(s, entry)
|
||||
if not left and not right then
|
||||
util.warning("no content for %s entry: %s", entry.kind, entry.path)
|
||||
return
|
||||
end
|
||||
|
||||
local key = entry_key(entry)
|
||||
local left_win, right_win = adopt_diff_wins(s, status_win)
|
||||
local want_pair = left and right
|
||||
|
||||
if s.last_shown_key == key then
|
||||
local intact = (want_pair and left_win and right_win)
|
||||
or (not want_pair and right_win and not left_win)
|
||||
if intact then
|
||||
local target = focus_left and (left_win or right_win) or status_win
|
||||
vim.api.nvim_set_current_win(target)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if not want_pair then
|
||||
if left_win and vim.api.nvim_win_is_valid(left_win) then
|
||||
pcall(vim.api.nvim_win_close, left_win, false)
|
||||
left_win = nil
|
||||
s.diff_left_win = nil
|
||||
end
|
||||
right_win = ensure_right_win(s, status_win, right_win)
|
||||
s.diff_right_win = right_win
|
||||
vim.w[right_win].git_diff_role = "right"
|
||||
local side = left or right
|
||||
---@cast side ow.Git.Diff.Side
|
||||
diff.set_diff(right_win, false)
|
||||
vim.api.nvim_win_set_buf(right_win, side.buf)
|
||||
if side.name then
|
||||
util.set_buf_name(side.buf, side.name)
|
||||
end
|
||||
s.last_shown_key = key
|
||||
vim.api.nvim_set_current_win(focus_left and right_win or status_win)
|
||||
return
|
||||
end
|
||||
---@cast left ow.Git.Diff.Side
|
||||
---@cast right ow.Git.Diff.Side
|
||||
|
||||
if left_win and not right_win then
|
||||
right_win = vsplit_at(left_win, "right")
|
||||
reset_diff_win(right_win)
|
||||
elseif right_win and not left_win then
|
||||
left_win = vsplit_at(right_win, "left")
|
||||
reset_diff_win(left_win)
|
||||
elseif not (left_win or right_win) then
|
||||
right_win = ensure_right_win(s, status_win, nil)
|
||||
left_win = vsplit_at(right_win, "left")
|
||||
reset_diff_win(left_win)
|
||||
local combined = vim.api.nvim_win_get_width(left_win)
|
||||
+ vim.api.nvim_win_get_width(right_win)
|
||||
vim.api.nvim_win_set_width(left_win, math.floor(combined / 2))
|
||||
end
|
||||
---@cast left_win -nil
|
||||
---@cast right_win -nil
|
||||
|
||||
vim.w[left_win].git_diff_role = "left"
|
||||
vim.w[right_win].git_diff_role = "right"
|
||||
s.diff_left_win = left_win
|
||||
s.diff_right_win = right_win
|
||||
|
||||
diff.update_pair(left_win, right_win, { left = left, right = right })
|
||||
s.last_shown_key = key
|
||||
|
||||
vim.api.nvim_set_current_win(focus_left and left_win or status_win)
|
||||
end
|
||||
|
||||
---@param focus_left boolean
|
||||
local function preview_or_open(focus_left)
|
||||
local s, entry = current_entry(vim.api.nvim_get_current_buf())
|
||||
if not s or not entry then
|
||||
return
|
||||
end
|
||||
view_entry(s, entry, focus_left)
|
||||
end
|
||||
|
||||
local function action_stage()
|
||||
local s, entry = current_entry(vim.api.nvim_get_current_buf())
|
||||
if not s or not entry then
|
||||
return
|
||||
end
|
||||
if entry.kind == "staged" then
|
||||
return
|
||||
end
|
||||
vim.system(
|
||||
{ "git", "add", "--", entry.path },
|
||||
{ cwd = s.repo.worktree },
|
||||
vim.schedule_wrap(function(obj)
|
||||
if obj.code ~= 0 then
|
||||
util.error("git add failed: %s", vim.trim(obj.stderr or ""))
|
||||
end
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
local function action_unstage()
|
||||
local s, entry = current_entry(vim.api.nvim_get_current_buf())
|
||||
if not s or not entry then
|
||||
return
|
||||
end
|
||||
if entry.kind ~= "staged" then
|
||||
return
|
||||
end
|
||||
local cmd = { "git", "restore", "--staged", "--" }
|
||||
if entry.orig then
|
||||
table.insert(cmd, entry.orig)
|
||||
end
|
||||
table.insert(cmd, entry.path)
|
||||
vim.system(
|
||||
cmd,
|
||||
{ cwd = s.repo.worktree },
|
||||
vim.schedule_wrap(function(obj)
|
||||
if obj.code ~= 0 then
|
||||
util.error(
|
||||
"git restore --staged failed: %s",
|
||||
vim.trim(obj.stderr or "")
|
||||
)
|
||||
end
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
local function action_discard()
|
||||
local s, entry = current_entry(vim.api.nvim_get_current_buf())
|
||||
if not s or not entry then
|
||||
return
|
||||
end
|
||||
if entry.kind == "staged" then
|
||||
util.warning("file has staged changes, unstage first with 'u'")
|
||||
return
|
||||
end
|
||||
|
||||
local prompt, action
|
||||
if entry.kind == "untracked" then
|
||||
local is_dir = entry.path:sub(-1) == "/"
|
||||
prompt = string.format(
|
||||
"Delete untracked %s %s?",
|
||||
is_dir and "directory" or "file",
|
||||
entry.path
|
||||
)
|
||||
action = function()
|
||||
local target = vim.fs.joinpath(s.repo.worktree, entry.path)
|
||||
local rc = vim.fn.delete(target, is_dir and "rf" or "")
|
||||
if rc ~= 0 then
|
||||
util.error("failed to delete %s", entry.path)
|
||||
end
|
||||
refresh(vim.api.nvim_get_current_buf())
|
||||
end
|
||||
elseif entry.kind == "unstaged" then
|
||||
prompt = string.format("Discard changes to %s?", entry.path)
|
||||
action = function()
|
||||
vim.system(
|
||||
{ "git", "checkout", "--", entry.path },
|
||||
{ cwd = s.repo.worktree },
|
||||
vim.schedule_wrap(function(obj)
|
||||
if obj.code ~= 0 then
|
||||
util.error(
|
||||
"git checkout failed: %s",
|
||||
vim.trim(obj.stderr or "")
|
||||
)
|
||||
end
|
||||
end)
|
||||
)
|
||||
end
|
||||
else
|
||||
return
|
||||
end
|
||||
|
||||
if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then
|
||||
action()
|
||||
end
|
||||
end
|
||||
|
||||
local function action_help()
|
||||
print(table.concat({
|
||||
"git status window",
|
||||
" <Tab> preview diff (keep focus)",
|
||||
" <CR> open diff (focus left pane)",
|
||||
" s stage file",
|
||||
" u unstage file",
|
||||
" X discard worktree changes (untracked: delete file)",
|
||||
" g? show this help",
|
||||
}, "\n"))
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
local function open(r)
|
||||
local existing = find_view()
|
||||
if existing then
|
||||
vim.api.nvim_set_current_win(existing)
|
||||
return
|
||||
end
|
||||
|
||||
local previous_win = vim.api.nvim_get_current_win()
|
||||
local bufnr, win = util.new_scratch({ split = "left" })
|
||||
vim.bo[bufnr].filetype = "gitstatus"
|
||||
|
||||
vim.wo[win].number = false
|
||||
vim.wo[win].relativenumber = false
|
||||
vim.wo[win].wrap = false
|
||||
vim.wo[win].signcolumn = "no"
|
||||
vim.wo[win].cursorline = true
|
||||
vim.wo[win].winfixwidth = true
|
||||
vim.api.nvim_win_set_width(win, WINDOW_WIDTH)
|
||||
|
||||
state[bufnr] = {
|
||||
repo = r,
|
||||
lines = {},
|
||||
win = win,
|
||||
invocation_win = previous_win,
|
||||
}
|
||||
|
||||
local function k(lhs, rhs, desc)
|
||||
vim.keymap.set(
|
||||
"n",
|
||||
lhs,
|
||||
rhs,
|
||||
{ buffer = bufnr, silent = true, desc = desc }
|
||||
)
|
||||
end
|
||||
k("<Tab>", function()
|
||||
preview_or_open(false)
|
||||
end, "Preview diff")
|
||||
k("<CR>", function()
|
||||
preview_or_open(true)
|
||||
end, "Open diff")
|
||||
k("s", action_stage, "Stage file")
|
||||
k("u", action_unstage, "Unstage file")
|
||||
k("X", action_discard, "Discard worktree changes")
|
||||
k("g?", action_help, "Help")
|
||||
|
||||
state[bufnr].unsubscribe = r:on("refresh", function()
|
||||
refresh(bufnr)
|
||||
end)
|
||||
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
|
||||
buffer = bufnr,
|
||||
group = group,
|
||||
callback = function()
|
||||
local s = state[bufnr]
|
||||
if not s then
|
||||
return
|
||||
end
|
||||
if s.unsubscribe then
|
||||
s.unsubscribe()
|
||||
end
|
||||
state[bufnr] = nil
|
||||
end,
|
||||
})
|
||||
|
||||
vim.api.nvim_set_current_win(previous_win)
|
||||
refresh(bufnr)
|
||||
end
|
||||
|
||||
function M.toggle()
|
||||
local status_win = find_view()
|
||||
if status_win then
|
||||
vim.api.nvim_win_close(status_win, false)
|
||||
return
|
||||
end
|
||||
local r = repo.resolve()
|
||||
if not r then
|
||||
util.warning("not in a git repository")
|
||||
return
|
||||
end
|
||||
open(r)
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user