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 = {} ---@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 r ow.Git.Repo ---@return string local function buf_name_for(r) return r.worktree .. "/Git Status" 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 unsubscribe fun()? ---@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 r ow.Git.Repo local function render(bufnr, r) local status = r.status local branch = status.branch local lines = {} local marks = {} local meta = {} local function label(row, len) table.insert(marks, { row = row, col = 0, end_col = len, hl = "Label" }) end local repo_line = vim.fn.fnamemodify(r.worktree, ":t") table.insert(lines, repo_line) table.insert(marks, { row = #lines - 1, col = 0, end_col = #repo_line, hl = "Directory", }) table.insert(lines, "Branch: " .. (branch.head or "?")) label(#lines - 1, 6) if branch.upstream then local up = "Upstream: " .. branch.upstream local extras = {} if branch.ahead > 0 then local col = #up + 1 up = up .. " +" .. branch.ahead table.insert(extras, { col = col, end_col = #up, hl = "GitUnpushed", }) end if branch.behind > 0 then local col = #up + 1 up = up .. " -" .. branch.behind table.insert(extras, { col = col, end_col = #up, hl = "GitUnpulled", }) end table.insert(lines, up) local row = #lines - 1 label(row, 8) for _, e in ipairs(extras) do e.row = row table.insert(marks, e) end end table.insert(lines, "") for _, section in ipairs(SECTIONS) do local rows = status:rows(section) if #rows > 0 then local name = display_name(section) local header = string.format("%s (%d)", name, #rows) table.insert(lines, header) local header_row = #lines - 1 meta[#lines] = { is_header = true, section = section } label(header_row, #name) table.insert(marks, { row = header_row, col = #name + 2, end_col = #header - 1, hl = "Number", }) 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 render(bufnr, s.repo) 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 ---@class ow.Git.StatusView.Pane ---@field buf integer ---@field name string? ---@param r ow.Git.Repo ---@param path string ---@return ow.Git.StatusView.Pane 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.StatusView.Pane 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.StatusView.Pane 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.StatusView.Pane? 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.StatusView.Pane? 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 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.api.nvim_win_call(win, function() vim.cmd("setlocal winfixwidth<") end) vim.cmd.clearjumps() return win end ---@param status_win integer ---@return integer? local function previous_target_win(status_win) local n = vim.fn.winnr("#") if n == 0 then return nil end local win = vim.fn.win_getid(n) if win == 0 or win == status_win or not valid_in_current_tab(win) then return nil end local cfg = vim.api.nvim_win_get_config(win) if cfg.relative and cfg.relative ~= "" then return nil end return win end ---@param status_win integer ---@param keep integer local function close_other_diff_wins(status_win, keep) for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do if win ~= status_win and win ~= keep and vim.wo[win].diff then pcall(vim.api.nvim_win_close, win, false) end end 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 -nil 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 target = previous_target_win(status_win) if not target then target = vsplit_at(status_win, "right") end close_other_diff_wins(status_win, target) vim.api.nvim_win_set_width(status_win, WINDOW_WIDTH) vim.api.nvim_win_call(target, function() vim.cmd.diffoff() end) if not (left and right) then local side = right or left ---@cast side ow.Git.StatusView.Pane vim.api.nvim_win_set_buf(target, side.buf) if side.name then util.set_buf_name(side.buf, side.name) end vim.api.nvim_set_current_win(focus_left and target or status_win) return end ---@cast left ow.Git.StatusView.Pane ---@cast right ow.Git.StatusView.Pane vim.api.nvim_win_set_buf(target, right.buf) if right.name then util.set_buf_name(right.buf, right.name) end local older = left.name or vim.api.nvim_buf_get_name(left.buf) local left_win vim.api.nvim_win_call(target, function() diff.split({ target = older, mods = { vertical = true }, }) left_win = vim.api.nvim_get_current_win() end) 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)") table.insert(lines, " <2-LeftMouse> open diff (focus left pane)") else table.insert(lines, " open file") table.insert(lines, " <2-LeftMouse> 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? local function setup_buffer(bufnr, r, placement, win) state[bufnr] = { repo = r, placement = placement, lines = {}, win = 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("<2-LeftMouse>", 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 ---@class ow.Git.StatusView.OpenOpts ---@field placement ow.Git.StatusView.Placement? ---@param opts? ow.Git.StatusView.OpenOpts 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(buf_name_for(r)) local visible = vim.fn.bufwinid(buf) if visible ~= -1 then vim.api.nvim_set_current_win(visible) r:refresh() return end if not state[buf] then vim.fn.bufload(buf) repo.bind(buf, r) util.setup_scratch(buf, {}) vim.bo[buf].filetype = "gitstatus" setup_buffer(buf, r, placement) end vim.bo[buf].bufhidden = placement == "sidebar" and "wipe" or "hide" local win = place(buf, placement) state[buf].win = win state[buf].placement = placement set_keymaps(buf, placement) if placement == "sidebar" then vim.api.nvim_set_current_win(previous_win) end refresh(buf) r:refresh() end ---@param opts? ow.Git.StatusView.OpenOpts function M.toggle(opts) local existing = find_view() if existing then vim.api.nvim_win_close(existing, false) return end M.open(opts) end return M