local Revision = require("git.core.revision") local diff = require("git.diff") local object = require("git.object") local repo = require("git.core.repo") local status = require("git.core.status") local util = require("git.core.util") local M = {} M.URI_PREFIX = "gitstatus://" ---@type ow.Git.StatusView.Placement[] M.PLACEMENTS = { "sidebar", "split", "current" } ---@type ow.Git.Status.Section[] local SECTIONS = { "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 section ow.Git.Status.Section ---@alias ow.Git.StatusView.Item ow.Git.Status.Row | ow.Git.StatusView.Header ---@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? ---@field diff_left_win integer? ---@field diff_right_win integer? ---@field unsubscribe fun()? ---@field last_shown_key string? ---@type table local state = {} local group = vim.api.nvim_create_augroup("ow.git.status_win", { clear = true }) 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 win integer? ---@return boolean local function valid_in_current_tab(win) if not win or not vim.api.nvim_win_is_valid(win) then return false end return vim.api.nvim_win_get_tabpage(win) == vim.api.nvim_get_current_tabpage() end ---@param s ow.Git.StatusView.State ---@return integer? local function win_for(s) if valid_in_current_tab(s.win) then return s.win end local win = find_view() s.win = win return win end ---@param row ow.Git.Status.Row ---@return string line ---@return string hl_group ---@return integer hl_len local function format_row(row) local entry = row.entry local orig if entry.kind == "changed" then ---@cast entry ow.Git.Status.ChangedEntry orig = entry.orig end local label = orig and (orig .. " -> " .. entry.path) or entry.path local mark = status.mark_for(entry, row.side) return string.format(" %s %s", mark.char, label), mark.hl, #mark.char end ---@param section ow.Git.Status.Section ---@return string local function display_name(section) return (section: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 _, section in ipairs(SECTIONS) do local rows = status:rows(section) if #rows > 0 then table.insert( lines, string.format("%s (%d)", display_name(section), #rows) ) meta[#lines] = { is_header = true, section = section } for _, row in ipairs(rows) do local line, hl, hl_len = format_row(row) table.insert(lines, line) meta[#lines] = row table.insert(marks, { row = #lines - 1, col = 2, end_col = 2 + hl_len, hl = hl, }) end table.insert(lines, "") end end util.set_buf_lines(bufnr, 0, -1, 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 path string ---@return ow.Git.Diff.Side local function index_pane(s, path) local rev = Revision.new({ stage = 0, path = path }) return { buf = object.buf_for(s.repo, rev), name = object.format_uri(rev), } end ---@param s ow.Git.StatusView.State ---@param row ow.Git.Status.Row ---@return ow.Git.Diff.Side? local function older_pane(s, row) local entry = row.entry if row.section == "staged" then ---@cast entry ow.Git.Status.ChangedEntry if entry.staged == "added" then return nil end return head_pane(s.repo, entry.orig or entry.path) end if row.section == "unstaged" then return index_pane(s, entry.path) end return nil end ---@param s ow.Git.StatusView.State ---@param row ow.Git.Status.Row ---@return ow.Git.Diff.Side? local function newer_pane(s, row) local entry = row.entry if row.section == "staged" then ---@cast entry ow.Git.Status.ChangedEntry if entry.staged == "deleted" then return nil end return index_pane(s, entry.path) end if row.section == "unstaged" then ---@cast entry ow.Git.Status.ChangedEntry if entry.unstaged == "deleted" then return nil end return worktree_pane(s.repo, entry.path) end if row.section == "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 = valid_in_current_tab(s.diff_left_win) and s.diff_left_win or nil local right = valid_in_current_tab(s.diff_right_win) and s.diff_right_win or nil 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 row ow.Git.Status.Row ---@return string local function row_key(row) local entry = row.entry local orig if entry.kind == "changed" then ---@cast entry ow.Git.Status.ChangedEntry orig = entry.orig end return row.section .. "|" .. entry.path .. "|" .. (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 row ow.Git.Status.Row ---@param focus_left boolean local function view_row(s, row, focus_left) local status_win = win_for(s) if not status_win then return end local left = older_pane(s, row) local right = newer_pane(s, row) if not left and not right then util.warning( "no content for %s row: %s", row.section, row.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 = row_key(row) 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.Row view_row(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.section == "staged" or item.section == "ignored" then return end for _, row in ipairs(s.repo.status:rows(item.section)) do table.insert(paths, row.entry.path) end else ---@cast item ow.Git.Status.Row if item.section == "staged" then return end table.insert(paths, item.entry.path) end if #paths == 0 then return end local args = { "add", "--" } vim.list_extend(args, paths) util.git(args, { cwd = s.repo.worktree, on_exit = function(result) if result.code ~= 0 then util.error("git add failed: %s", vim.trim(result.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 local rows if item.is_header then if item.section ~= "staged" then return end rows = s.repo.status:rows("staged") else ---@cast item ow.Git.Status.Row if item.section ~= "staged" then return end rows = { item } end ---@cast rows ow.Git.Status.Row[] if #rows == 0 then return end local args = { "restore", "--staged", "--" } for _, row in ipairs(rows) do local entry = row.entry if entry.kind == "changed" then ---@cast entry ow.Git.Status.ChangedEntry if entry.orig then table.insert(args, entry.orig) end end table.insert(args, entry.path) end util.git(args, { cwd = s.repo.worktree, on_exit = function(result) if result.code ~= 0 then util.error( "git restore --staged failed: %s", vim.trim(result.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.Row if item.section == "staged" then util.warning("file has staged changes, unstage first with 'u'") return end local entry = item.entry local path = entry.path local prompt, action if item.section == "untracked" then local is_dir = path:sub(-1) == "/" prompt = string.format( "Delete untracked %s %s?", is_dir and "directory" or "file", path ) action = function() local target = vim.fs.joinpath(s.repo.worktree, path) local rc = vim.fn.delete(target, is_dir and "rf" or "") if rc ~= 0 then util.error("failed to delete %s", path) end refresh(vim.api.nvim_get_current_buf()) end elseif item.section == "unstaged" then prompt = string.format("Discard changes to %s?", path) action = function() util.git({ "checkout", "--", path }, { cwd = s.repo.worktree, on_exit = function(result) if result.code ~= 0 then util.error( "git checkout failed: %s", vim.trim(result.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, " 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, " 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("", 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("change", 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", "", function() preview_or_open(false) end, { buffer = bufnr, silent = true, desc = "Preview diff" }) else pcall(vim.keymap.del, "n", "", { buffer = bufnr }) end end ---@param opts? { placement: ow.Git.StatusView.Placement? } function M.open(opts) opts = opts or {} local placement = opts.placement or "sidebar" if not vim.tbl_contains(M.PLACEMENTS, placement) then util.error( "invalid placement: %s (expected one of %s)", placement, table.concat(M.PLACEMENTS, ", ") ) return end 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) util.setup_scratch(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