feat(git): add status view as window with URI buffers

This commit is contained in:
2026-05-06 01:58:59 +02:00
parent 40703c6db1
commit 331a4e7662
3 changed files with 221 additions and 40 deletions
+19
View File
@@ -82,6 +82,13 @@ function M.init()
require("git.log_view").read_uri(args.buf)
end,
})
vim.api.nvim_create_autocmd("BufReadCmd", {
pattern = "gitstatus://*",
group = group,
callback = function(args)
require("git.status_view").read_uri(args.buf)
end,
})
vim.api.nvim_create_user_command("Glog", function(opts)
require("git.log_view").run_glog(opts)
end, {
@@ -141,6 +148,18 @@ function M.init()
vim.api.nvim_create_user_command("Grefresh", function()
require("git.repo").refresh_all()
end, { desc = "Refresh git status for all repos" })
vim.api.nvim_create_user_command("Gstatus", function(opts)
require("git.status_view").open({
placement = opts.fargs[1] --[[@as ow.Git.StatusView.Placement]] or "split",
})
end, {
nargs = "?",
complete = function()
return require("git.status_view").PLACEMENTS
end,
desc = "Open git status view",
})
end
return M
+1 -4
View File
@@ -56,10 +56,7 @@ function Repo:_fetch_status()
{ cwd = self.worktree, text = true },
vim.schedule_wrap(function(obj)
if obj.code ~= 0 then
util.error(
"git status failed: %s",
vim.trim(obj.stderr or "")
)
util.error("git status failed: %s", vim.trim(obj.stderr or ""))
return
end
self.status = status.parse(obj.stdout or "")
+198 -33
View File
@@ -6,12 +6,30 @@ 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.State
---@field repo ow.Git.Repo
---@field placement ow.Git.StatusView.Placement
---@field lines table<integer, ow.Git.Status.Entry>
---@field win integer?
---@field invocation_win integer?
@@ -169,12 +187,12 @@ local function refresh(bufnr)
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
s.last_shown_key = nil
render(bufnr, status)
if not saved_path then
return
@@ -382,6 +400,18 @@ local function view_entry(s, entry, focus_left)
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
@@ -553,43 +583,64 @@ local function action_discard()
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"))
---@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, " g? show this help")
print(table.concat(lines, "\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
---Place an existing or new gitstatus buffer in a window per `placement`.
---Sidebar uses a left split with fixed width and winfixwidth; split uses
---the default split direction (above/below per `splitbelow`); current
---swaps into the current window.
---@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 previous_win = vim.api.nvim_get_current_win()
local bufnr, win = util.new_scratch({ split = "left" })
vim.bo[bufnr].filetype = "gitstatus"
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 = previous_win,
invocation_win = invocation_win,
}
local function k(lhs, rhs, desc)
@@ -601,15 +652,19 @@ local function open(r)
)
end
k("<Tab>", function()
if state[bufnr].placement == "sidebar" then
preview_or_open(false)
end, "Preview diff")
end
end, "Preview diff (sidebar)")
k("<CR>", function()
preview_or_open(true)
end, "Open diff")
end, "Open")
k("s", action_stage, "Stage file")
k("u", action_unstage, "Unstage file")
k("X", action_discard, "Discard worktree changes")
k("g?", action_help, "Help")
k("g?", function()
action_help(state[bufnr].placement)
end, "Help")
state[bufnr].unsubscribe = r:on("refresh", function()
refresh(bufnr)
@@ -628,23 +683,133 @@ local function open(r)
state[bufnr] = nil
end,
})
end
vim.api.nvim_set_current_win(previous_win)
---Update an existing status view's state and re-place its buffer.
---@param bufnr integer
---@param placement ow.Git.StatusView.Placement
---@param invocation_win integer
local function reuse(bufnr, placement, invocation_win)
local win = place(bufnr, placement)
local s = state[bufnr]
if s then
s.win = win
s.invocation_win = invocation_win
s.placement = placement
end
if placement == "sidebar" then
vim.api.nvim_set_current_win(invocation_win)
end
refresh(bufnr)
end
function M.toggle()
local status_win = find_view()
if status_win then
vim.api.nvim_win_close(status_win, false)
---@param r ow.Git.Repo
---@param placement ow.Git.StatusView.Placement
local function open(r, placement)
local previous_win = vim.api.nvim_get_current_win()
local name = M.URI_PREFIX .. r.worktree
local existing_buf = find_buf(name)
if existing_buf then
local visible = vim.fn.bufwinid(existing_buf)
if visible ~= -1 then
vim.api.nvim_set_current_win(visible)
return
end
reuse(existing_buf, placement, previous_win)
return
end
local bufnr = vim.api.nvim_create_buf(false, true)
vim.bo[bufnr].buftype = "nofile"
vim.bo[bufnr].swapfile = false
vim.bo[bufnr].modifiable = false
vim.bo[bufnr].bufhidden = placement == "sidebar" and "wipe" or "hide"
vim.api.nvim_buf_set_name(bufnr, name)
vim.bo[bufnr].filetype = "gitstatus"
local win = place(bufnr, placement)
setup_buffer(bufnr, r, placement, win, previous_win)
if placement == "sidebar" then
vim.api.nvim_set_current_win(previous_win)
end
refresh(bufnr)
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.warning("not in a git repository")
util.error("not in a git repository")
return
end
open(r)
open(r, placement)
end
---Set up an existing buffer (created by `:e gitstatus://<worktree>`) as a
---status view. Treated as `current` placement. Relative URIs are made
---absolute against the cwd.
---@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 })
reuse(existing, "current", win)
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)
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