From 331a4e76621aaa2389ea2379f9fd7fdb64934ccb Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Wed, 6 May 2026 01:58:59 +0200 Subject: [PATCH] feat(git): add status view as window with URI buffers --- lua/git/init.lua | 19 ++++ lua/git/repo.lua | 5 +- lua/git/status_view.lua | 237 ++++++++++++++++++++++++++++++++++------ 3 files changed, 221 insertions(+), 40 deletions(-) diff --git a/lua/git/init.lua b/lua/git/init.lua index 2e5dee9..eed4b28 100644 --- a/lua/git/init.lua +++ b/lua/git/init.lua @@ -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 diff --git a/lua/git/repo.lua b/lua/git/repo.lua index edb9f1c..588142a 100644 --- a/lua/git/repo.lua +++ b/lua/git/repo.lua @@ -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 "") diff --git a/lua/git/status_view.lua b/lua/git/status_view.lua index ddd7d19..1ff093b 100644 --- a/lua/git/status_view.lua +++ b/lua/git/status_view.lua @@ -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 ---@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", - " preview diff (keep focus)", - " 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, " preview diff (keep focus)") + table.insert(lines, " open diff (focus left pane)") + else + table.insert(lines, " 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 - vim.wo[win].winfixwidth = true - vim.api.nvim_win_set_width(win, WINDOW_WIDTH) + 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("", function() - preview_or_open(false) - end, "Preview diff") + if state[bufnr].placement == "sidebar" then + preview_or_open(false) + end + end, "Preview diff (sidebar)") k("", 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://`) 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