Files
nvim/lua/git/status_view.lua
T

787 lines
22 KiB
Lua

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 = {}
M.URI_PREFIX = "gitstatus://"
---@type ow.Git.StatusView.Placement[]
M.PLACEMENTS = { "sidebar", "split", "current" }
---@type ow.Git.Status.EntryKind[]
local KINDS = { "untracked", "unstaged", "staged", "unmerged" }
local WINDOW_WIDTH = 50
---@param name string
---@return integer? bufnr
local function find_buf(name)
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b) == name then
return b
end
end
end
---@alias ow.Git.StatusView.Placement "sidebar"|"split"|"current"
---@class ow.Git.StatusView.Header
---@field is_header true
---@field kind ow.Git.Status.EntryKind
---@alias ow.Git.StatusView.Item ow.Git.Status.Entry | ow.Git.StatusView.Header
---@class ow.Git.StatusView.State
---@field repo ow.Git.Repo
---@field placement ow.Git.StatusView.Placement
---@field lines table<integer, ow.Git.StatusView.Item>
---@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?
---@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)
)
meta[#lines] = { is_header = true, kind = kind }
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
util.buf_set_lines(bufnr, 0, -1, false, lines)
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 bufnr integer
local function refresh(bufnr)
local s = state[bufnr]
if not s or not vim.api.nvim_buf_is_valid(bufnr) then
return
end
s.last_shown_key = nil
render(bufnr, s.repo.status)
end
---@param bufnr integer
---@return ow.Git.StatusView.State?
---@return ow.Git.StatusView.Item?
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
if s.placement ~= "sidebar" then
local pane = right or left
---@cast pane ow.Git.Diff.Side
diff.set_diff(status_win, false)
vim.cmd.normal({ "m'", bang = true })
vim.api.nvim_win_set_buf(status_win, pane.buf)
if pane.name then
util.set_buf_name(pane.buf, pane.name)
end
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, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item or item.is_header then
return
end
---@cast item ow.Git.Status.Entry
view_entry(s, item, focus_left)
end
local function action_stage()
local s, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item then
return
end
local paths = {}
if item.is_header then
if item.kind == "staged" or item.kind == "ignored" then
return
end
for _, e in ipairs(s.repo.status:by_kind(item.kind)) do
table.insert(paths, e.path)
end
else
---@cast item ow.Git.Status.Entry
if item.kind == "staged" then
return
end
table.insert(paths, item.path)
end
if #paths == 0 then
return
end
local cmd = { "git", "add", "--" }
vim.list_extend(cmd, paths)
vim.system(
cmd,
{ 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, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item then
return
end
if item.kind ~= "staged" then
return
end
local cmd = { "git", "restore", "--staged", "--" }
local entries
if item.is_header then
entries = s.repo.status:by_kind("staged")
else
---@cast item ow.Git.Status.Entry
entries = { item }
end
if #entries == 0 then
return
end
for _, e in ipairs(entries) do
if e.orig then
table.insert(cmd, e.orig)
end
table.insert(cmd, e.path)
end
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, item = current_entry(vim.api.nvim_get_current_buf())
if not s or not item or item.is_header then
return
end
---@cast item ow.Git.Status.Entry
if item.kind == "staged" then
util.warning("file has staged changes, unstage first with 'u'")
return
end
local prompt, action
if item.kind == "untracked" then
local is_dir = item.path:sub(-1) == "/"
prompt = string.format(
"Delete untracked %s %s?",
is_dir and "directory" or "file",
item.path
)
action = function()
local target = vim.fs.joinpath(s.repo.worktree, item.path)
local rc = vim.fn.delete(target, is_dir and "rf" or "")
if rc ~= 0 then
util.error("failed to delete %s", item.path)
end
refresh(vim.api.nvim_get_current_buf())
end
elseif item.kind == "unstaged" then
prompt = string.format("Discard changes to %s?", item.path)
action = function()
vim.system(
{ "git", "checkout", "--", item.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
---@param placement ow.Git.StatusView.Placement
local function action_help(placement)
local lines = { "git status view" }
if placement == "sidebar" then
table.insert(lines, " <Tab> preview diff (keep focus)")
table.insert(lines, " <CR> open diff (focus left pane)")
else
table.insert(lines, " <CR> open file")
end
table.insert(lines, " s stage file")
table.insert(lines, " u unstage file")
table.insert(
lines,
" X discard worktree changes (untracked: delete file)"
)
table.insert(lines, " R refresh")
table.insert(lines, " g? show this help")
print(table.concat(lines, "\n"))
end
---@param bufnr integer
---@param placement ow.Git.StatusView.Placement
---@return integer win
local function place(bufnr, placement)
local split
if placement == "sidebar" then
split = "left"
elseif placement == "current" then
split = false
end
local win = util.place_buf(bufnr, split)
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
if placement == "sidebar" then
vim.wo[win].winfixwidth = true
vim.api.nvim_win_set_width(win, WINDOW_WIDTH)
end
return win
end
---@param bufnr integer
---@param r ow.Git.Repo
---@param placement ow.Git.StatusView.Placement
---@param win integer?
---@param invocation_win integer?
local function setup_buffer(bufnr, r, placement, win, invocation_win)
state[bufnr] = {
repo = r,
placement = placement,
lines = {},
win = win,
invocation_win = invocation_win,
}
local function k(lhs, rhs, desc)
vim.keymap.set(
"n",
lhs,
rhs,
{ buffer = bufnr, silent = true, desc = desc }
)
end
k("<CR>", function()
preview_or_open(true)
end, "Open")
k("s", action_stage, "Stage file")
k("u", action_unstage, "Unstage file")
k("X", action_discard, "Discard worktree changes")
k("R", function()
r:refresh()
end, "Refresh")
k("g?", function()
action_help(state[bufnr].placement)
end, "Help")
state[bufnr].unsubscribe = r:on("refresh", function()
refresh(bufnr)
end)
vim.api.nvim_create_autocmd("BufEnter", {
buffer = bufnr,
group = group,
callback = function()
r:refresh()
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,
})
end
---@param bufnr integer
---@param placement ow.Git.StatusView.Placement
local function set_keymaps(bufnr, placement)
if placement == "sidebar" then
vim.keymap.set("n", "<Tab>", function()
preview_or_open(false)
end, { buffer = bufnr, silent = true, desc = "Preview diff" })
else
pcall(vim.keymap.del, "n", "<Tab>", { buffer = bufnr })
end
end
---@param opts? { placement: ow.Git.StatusView.Placement? }
function M.open(opts)
opts = opts or {}
local placement = opts.placement or "sidebar"
local r = repo.resolve()
if not r then
util.error("not in a git repository")
return
end
local previous_win = vim.api.nvim_get_current_win()
local buf = vim.fn.bufadd(M.URI_PREFIX .. r.worktree)
local visible = vim.fn.bufwinid(buf)
if visible ~= -1 then
vim.api.nvim_set_current_win(visible)
r:refresh()
return
end
local was_loaded = vim.api.nvim_buf_is_loaded(buf)
local win = place(buf, placement)
vim.bo[buf].bufhidden = placement == "sidebar" and "wipe" or "hide"
local s = state[buf]
if s then
s.win = win
s.invocation_win = previous_win
s.placement = placement
end
set_keymaps(buf, placement)
if placement == "sidebar" then
vim.api.nvim_set_current_win(previous_win)
end
if was_loaded then
refresh(buf)
end
r:refresh()
end
---@param buf integer
function M.read_uri(buf)
local name = vim.api.nvim_buf_get_name(buf)
local raw = name:sub(#M.URI_PREFIX + 1)
if raw == "" then
return
end
local worktree = vim.fs.abspath(raw)
local r = repo.resolve(worktree)
if not r then
util.error("not a git worktree: %s", raw)
return
end
if r.worktree ~= worktree then
util.warning("%s is not a worktree root, using %s", raw, r.worktree)
end
local canonical = M.URI_PREFIX .. r.worktree
if name ~= canonical then
local existing = find_buf(canonical)
if existing and existing ~= buf then
local win = vim.api.nvim_get_current_win()
if vim.api.nvim_win_get_buf(win) == buf then
vim.api.nvim_win_set_buf(win, existing)
end
vim.api.nvim_buf_delete(buf, { force = true })
local s = state[existing]
if s then
s.win = win
s.placement = "current"
end
refresh(existing)
r:refresh()
return
end
pcall(vim.api.nvim_buf_set_name, buf, canonical)
end
repo.bind(buf, r)
vim.bo[buf].buftype = "nofile"
vim.bo[buf].swapfile = false
vim.bo[buf].modifiable = false
vim.bo[buf].bufhidden = "hide"
vim.bo[buf].filetype = "gitstatus"
---@type integer?
local win = vim.fn.bufwinid(buf)
if win == -1 then
win = nil
end
if not state[buf] then
setup_buffer(buf, r, "current", win, nil)
else
state[buf].win = win
end
refresh(buf)
r:refresh()
end
function M.toggle()
local existing = find_view()
if existing then
vim.api.nvim_win_close(existing, false)
return
end
M.open({ placement = "sidebar" })
end
return M