From 5a3e39574df8c9700c265bd27a8f5fd8a7fd2e92 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Mon, 27 Apr 2026 10:44:59 +0200 Subject: [PATCH] feat(git): add custom status sidebar and diff viewer --- after/syntax/git.vim | 2 +- lua/git/init.lua | 73 +++ lua/{git.lua => git/repo.lua} | 149 +++--- lua/git/status_win.lua | 915 ++++++++++++++++++++++++++++++++++ plugins/nvim-tree.lua | 4 +- plugins/onedark.lua | 6 +- plugins/vim-fugitive.lua | 33 -- syntax/gitstatus.vim | 44 ++ 8 files changed, 1111 insertions(+), 115 deletions(-) create mode 100644 lua/git/init.lua rename lua/{git.lua => git/repo.lua} (71%) create mode 100644 lua/git/status_win.lua create mode 100644 syntax/gitstatus.vim diff --git a/after/syntax/git.vim b/after/syntax/git.vim index 442d338..889ef71 100644 --- a/after/syntax/git.vim +++ b/after/syntax/git.vim @@ -14,7 +14,7 @@ syntax match gitlogGraphLine /^[*|\\\/_ ]\+$/ \ contains=gitlogGraph highlight default link gitlogGraph Comment -highlight default link gitlogHash Identifier +highlight default link gitlogHash GitSha highlight default link gitlogDate Number highlight default link gitlogAuthor String highlight default link gitlogRef Constant diff --git a/lua/git/init.lua b/lua/git/init.lua new file mode 100644 index 0000000..7713094 --- /dev/null +++ b/lua/git/init.lua @@ -0,0 +1,73 @@ +local repo = require("git.repo") + +local HIGHLIGHTS = { + GitDeleted = "Removed", + GitIgnored = "Comment", + GitUnstaged = "Changed", + GitRenamed = "GitStaged", + GitSha = "Identifier", + GitStaged = "Constant", + GitUnmerged = "Todo", + GitUnpulled = "Removed", + GitUnpushed = "Added", + GitUntracked = "Added", +} + +local M = {} + +function M.status() + return vim.b.git_status or "" +end + +---@param path string +---@return string? +function M.head(path) + return repo.head(path) +end + +function M.setup() + for name, link in pairs(HIGHLIGHTS) do + vim.api.nvim_set_hl(0, name, { link = link, default = true }) + end + vim.filetype.add({ + pattern = { + ["git://[^/]+/(.+)"] = function(_, bufnr, inner) + return vim.filetype.match({ filename = inner, buf = bufnr }) + end, + }, + }) + local group = vim.api.nvim_create_augroup("ow.git", { clear = true }) + vim.api.nvim_create_autocmd( + { "BufReadPost", "BufNewFile", "BufWritePost", "FileChangedShellPost" }, + { + group = group, + callback = function(args) + repo.refresh_buf(args.buf) + end, + } + ) + vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { + group = group, + callback = function(args) + repo.unregister(args.buf) + end, + }) + vim.api.nvim_create_autocmd("FocusGained", { + group = group, + callback = function() + repo.refresh_buf(vim.api.nvim_get_current_buf()) + end, + }) + vim.api.nvim_create_autocmd("VimLeavePre", { + group = group, + callback = function() + repo.stop_all() + end, + }) + + vim.keymap.set("n", "gg", function() + require("git.status_win").toggle() + end, { desc = "Toggle git status sidebar" }) +end + +return M diff --git a/lua/git.lua b/lua/git/repo.lua similarity index 71% rename from lua/git.lua rename to lua/git/repo.lua index 94c0cdd..a4b18e6 100644 --- a/lua/git.lua +++ b/lua/git/repo.lua @@ -1,15 +1,5 @@ local util = require("util") -local HIGHLIGHTS = { - GitDeleted = "Statement", - GitDirty = "Statement", - GitIgnored = "Comment", - GitMerge = "Constant", - GitNew = "PreProc", - GitRenamed = "PreProc", - GitStaged = "Constant", -} - local UNMERGED = { DD = true, AU = true, @@ -20,36 +10,44 @@ local UNMERGED = { UU = true, } ----@param code string ----@return string? -local function format(code) +---@param code string porcelain v1 XY code +---@return string? char +---@return string? hl_group +local function indicator(code) if code == "" then return nil end - local char, hl if code == "??" then - char, hl = "?", "GitNew" - elseif code == "!!" then - char, hl = "!", "GitIgnored" - elseif UNMERGED[code] then - char, hl = "U", "GitMerge" - else - local x, y = code:sub(1, 1), code:sub(2, 2) - if x == "R" or y == "R" then - char, hl = "R", "GitRenamed" - elseif y == "M" or y == "T" then - char, hl = "M", "GitDirty" - elseif y == "D" then - char, hl = "D", "GitDeleted" - elseif y == " " and x == "D" then - char, hl = "D", "GitStaged" - elseif y == " " and x == "A" then - char, hl = "A", "GitStaged" - elseif y == " " and x ~= " " then - char, hl = "M", "GitStaged" - else - char, hl = "M", "GitDirty" - end + return "?", "GitUntracked" + end + if code == "!!" then + return "!", "GitIgnored" + end + if UNMERGED[code] then + return "U", "GitUnmerged" + end + local x, y = code:sub(1, 1), code:sub(2, 2) + if x == "R" or y == "R" then + return "R", "GitRenamed" + end + if y == " " and x ~= " " then + return x, "GitStaged" + end + if y == "D" then + return "D", "GitDeleted" + end + if y == "M" or y == "T" then + return "M", "GitUnstaged" + end + return "M", "GitUnstaged" +end + +---@param code string +---@return string? +local function format(code) + local char, hl = indicator(code) + if not char then + return nil end return string.format("%%#%s#%s%%*", hl, char) end @@ -243,46 +241,45 @@ local function refresh_buf(buf) repo:refresh() end -local M = {} - -function M.status() - return vim.b.git_status or "" -end - -function M.setup() - for name, link in pairs(HIGHLIGHTS) do - vim.api.nvim_set_hl(0, name, { link = link, default = true }) +local function stop_all() + for _, repo in pairs(repo_by_gitdir) do + repo:stop_watcher() end - local group = vim.api.nvim_create_augroup("ow.git", { clear = true }) - vim.api.nvim_create_autocmd( - { "BufReadPost", "BufNewFile", "BufWritePost", "FileChangedShellPost" }, - { - group = group, - callback = function(args) - refresh_buf(args.buf) - end, - } - ) - vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { - group = group, - callback = function(args) - unregister(args.buf) - end, - }) - vim.api.nvim_create_autocmd("FocusGained", { - group = group, - callback = function() - refresh_buf(vim.api.nvim_get_current_buf()) - end, - }) - vim.api.nvim_create_autocmd("VimLeavePre", { - group = group, - callback = function() - for _, repo in pairs(repo_by_gitdir) do - repo:stop_watcher() - end - end, - }) end -return M +---@param path string +---@return string? +local function head(path) + local gitdir = resolve(path) + if not gitdir then + return nil + end + local f = io.open(vim.fs.joinpath(gitdir, "HEAD"), "r") + if not f then + return nil + end + local first = f:read("*l") + f:close() + if not first then + return nil + end + local branch = first:match("^ref:%s*refs/heads/(%S+)") + if branch then + return branch + end + local sha = first:match("^(%x+)") + if sha then + return sha:sub(1, 7) + end + return nil +end + +return { + UNMERGED = UNMERGED, + head = head, + indicator = indicator, + refresh_buf = refresh_buf, + resolve = resolve, + stop_all = stop_all, + unregister = unregister, +} diff --git a/lua/git/status_win.lua b/lua/git/status_win.lua new file mode 100644 index 0000000..d697181 --- /dev/null +++ b/lua/git/status_win.lua @@ -0,0 +1,915 @@ +local log = require("log") +local repo = require("git.repo") + +local M = {} + +local SECTIONS = { + "Untracked", + "Unstaged", + "Staged", + "Unmerged", + "Unpushed", + "Unpulled", +} +local SIDEBAR_WIDTH = 50 + +---@class ow.Git.FileEntry +---@field section string +---@field path string +---@field orig string? +---@field x string? +---@field y string? + +---@class ow.Git.CommitEntry +---@field section string +---@field sha string +---@field subject string? + +---@alias ow.Git.StatusEntry ow.Git.FileEntry | ow.Git.CommitEntry + +---@class ow.Git.StatusState +---@field gitdir string +---@field worktree string +---@field lines table +---@field diff_left_win integer? +---@field diff_right_win integer? +---@field user_aucmd integer? +---@field last_shown_key string? + +---@type table +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_sidebar() + 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 entry ow.Git.StatusEntry +---@return string? +local function entry_code(entry) + if entry.section == "Untracked" then + return "??" + elseif entry.section == "Unmerged" then + return (entry.x or " ") .. (entry.y or " ") + elseif entry.section == "Staged" then + return (entry.x or " ") .. " " + elseif entry.section == "Unstaged" then + return " " .. (entry.y or " ") + end +end + +---@param entry ow.Git.StatusEntry +---@return string? line +---@return string? hl_group +---@return integer? hl_len byte length of the symbol portion at column 2 +local function format_entry(entry) + if entry.sha then + return string.format(" %s %s", entry.sha, entry.subject or ""), + "GitSha", + #entry.sha + end + local code = entry_code(entry) + if not code then + return nil + end + local char, hl = repo.indicator(code) + if not char then + return nil + end + local label = entry.orig and (entry.orig .. " -> " .. entry.path) + or entry.path + return string.format(" %s %s", char, label), hl, #char +end + +---@class ow.Git.BranchInfo +---@field head string? +---@field upstream string? +---@field ahead integer +---@field behind integer + +---@param line string '## branch.line' from porcelain v1 +---@return ow.Git.BranchInfo +local function parse_branch_line(line) + local info = { ahead = 0, behind = 0 } + local content = line:sub(4) + local arrow = content:find("...", 1, true) + if not arrow then + info.head = content + return info + end + info.head = content:sub(1, arrow - 1) + local rest = content:sub(arrow + 3) + local bracket = rest:find(" %[") + if not bracket then + info.upstream = rest + return info + end + info.upstream = rest:sub(1, bracket - 1) + local inside = rest:match("%[([^%]]+)%]") + if inside then + info.ahead = (tonumber(inside:match("ahead (%d+)")) or 0) --[[@as integer]] + info.behind = (tonumber(inside:match("behind (%d+)")) or 0) --[[@as integer]] + end + return info +end + +---@param worktree string +---@param callback fun(branch: ow.Git.BranchInfo, groups: table) +local function fetch_status(worktree, callback) + vim.system({ + "git", + "-c", + "core.quotePath=false", + "status", + "--porcelain=v1", + "--branch", + }, { cwd = worktree, text = true }, function(obj) + vim.schedule(function() + local branch = { ahead = 0, behind = 0 } + local groups = { + Untracked = {}, + Unstaged = {}, + Staged = {}, + Unmerged = {}, + Unpushed = {}, + Unpulled = {}, + } + if obj.code == 0 then + for line in (obj.stdout or ""):gmatch("[^\r\n]+") do + if line:sub(1, 2) == "##" then + branch = parse_branch_line(line) + else + local x = line:sub(1, 1) + local y = line:sub(2, 2) + local rest = line:sub(4) + local orig + local arrow = rest:find(" -> ", 1, true) + if arrow then + orig = rest:sub(1, arrow - 1) + rest = rest:sub(arrow + 4) + end + local entry = { + section = nil, + path = rest, + orig = orig, + x = x, + y = y, + } + if x == "?" and y == "?" then + entry.section = "Untracked" + table.insert(groups.Untracked, entry) + elseif repo.UNMERGED[x .. y] then + entry.section = "Unmerged" + table.insert(groups.Unmerged, entry) + else + if x ~= " " then + table.insert( + groups.Staged, + vim.tbl_extend( + "force", + entry, + { section = "Staged" } + ) + ) + end + if y ~= " " then + table.insert( + groups.Unstaged, + vim.tbl_extend( + "force", + entry, + { section = "Unstaged" } + ) + ) + end + end + end + end + end + local fetches = {} + if branch.upstream and branch.ahead > 0 then + table.insert( + fetches, + { section = "Unpushed", range = "@{upstream}..HEAD" } + ) + end + if branch.upstream and branch.behind > 0 then + table.insert( + fetches, + { section = "Unpulled", range = "HEAD..@{upstream}" } + ) + end + if #fetches == 0 then + callback(branch, groups) + return + end + local pending = #fetches + for _, f in ipairs(fetches) do + vim.system({ + "git", + "log", + "--format=%h %s", + f.range, + }, { cwd = worktree, text = true }, function( + log_obj + ) + vim.schedule(function() + if log_obj.code == 0 then + for line in + (log_obj.stdout or ""):gmatch("[^\r\n]+") + do + local sha, subject = + line:match("^(%S+)%s+(.+)$") + if sha then + table.insert(groups[f.section], { + section = f.section, + sha = sha, + subject = subject, + }) + end + end + end + pending = pending - 1 + if pending == 0 then + callback(branch, groups) + end + end) + end) + end + end) + end) +end + +---@param bufnr integer +---@param branch ow.Git.BranchInfo +---@param groups table +local function render(bufnr, branch, groups) + 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 entries = groups[section] + if entries and #entries > 0 then + table.insert(lines, string.format("%s (%d)", section, #entries)) + for _, entry in ipairs(entries) do + local line, hl, hl_len = format_entry(entry) + if line then + table.insert(lines, line) + meta[#lines] = entry + if hl and hl_len then + table.insert(marks, { + row = #lines - 1, + col = 2, + end_col = 2 + hl_len, + hl = hl, + }) + end + end + end + table.insert(lines, "") + end + end + + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modifiable = false + 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 then + return + end + + local saved_path, saved_sha + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(win) == bufnr then + local lnum = vim.api.nvim_win_get_cursor(win)[1] + local entry = s.lines[lnum] + if entry then + saved_path = entry.path + saved_sha = entry.sha + end + break + end + end + + fetch_status(s.worktree, function(branch, groups) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + render(bufnr, branch, groups) + if not saved_path and not saved_sha then + return + end + for lnum, entry in pairs(s.lines) do + if + (saved_path and entry.path == saved_path) + or (saved_sha and entry.sha == saved_sha) + then + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_buf(win) == bufnr then + pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 }) + end + end + break + end + end + end) +end + +---@param bufnr integer +---@return ow.Git.StatusState? +---@return ow.Git.StatusEntry? +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 buf integer +---@param worktree string +---@param path string +local function attach_index_writer(buf, worktree, path) + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = buf, + callback = function() + local body = table.concat( + vim.api.nvim_buf_get_lines(buf, 0, -1, false), + "\n" + ) .. "\n" + local hash = vim.system( + { "git", "hash-object", "-w", "--stdin" }, + { cwd = worktree, stdin = body, text = true } + ):wait() + if hash.code ~= 0 then + log.error("git hash-object failed: %s", hash.stderr or "") + return + end + local sha = vim.trim(hash.stdout or "") + local mode = "100644" + local ls = vim.system( + { "git", "ls-files", "-s", "--", path }, + { cwd = worktree, text = true } + ):wait() + if ls.code == 0 and ls.stdout then + local m = ls.stdout:match("^(%d+)") + if m then + mode = m + end + end + local upd = vim.system({ + "git", + "update-index", + "--cacheinfo", + mode .. "," .. sha .. "," .. path, + }, { cwd = worktree, text = true }):wait() + if upd.code ~= 0 then + log.error("git update-index failed: %s", upd.stderr or "") + return + end + vim.bo[buf].modified = false + end, + }) +end + +---@param worktree string +---@param ref string '' for index, 'HEAD' for HEAD +---@param path string +---@param is_index boolean? true to hook :w to update the git index +---@return integer +local function git_show_buf(worktree, ref, path, is_index) + local result = vim.system( + { "git", "show", ref .. ":" .. path }, + { cwd = worktree, text = true } + ):wait() + local content = result.code == 0 and (result.stdout or "") or "" + local lines = vim.split(content, "\n", { plain = true, trimempty = false }) + if #lines > 0 and lines[#lines] == "" then + table.remove(lines) + end + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].buftype = is_index and "acwrite" or "nofile" + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].swapfile = false + if not is_index then + vim.bo[buf].modifiable = false + end + if is_index then + attach_index_writer(buf, worktree, path) + end + vim.bo[buf].modified = false + return buf +end + +---@return integer +local function empty_buf() + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].swapfile = false + vim.bo[buf].modifiable = false + vim.bo[buf].modified = false + return buf +end + +---@param abs_path string +---@return integer +local function load_file_buf(abs_path) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if + vim.api.nvim_buf_is_loaded(buf) + and vim.api.nvim_buf_get_name(buf) == abs_path + then + return buf + end + end + local buf = vim.fn.bufadd(abs_path) + vim.fn.bufload(buf) + return buf +end + +---@class ow.Git.DiffSide +---@field buf integer +---@field name string? + +---@class ow.Git.DiffPair +---@field left ow.Git.DiffSide +---@field right ow.Git.DiffSide + +---@param worktree string +---@param path string +---@param content boolean +---@return ow.Git.DiffSide +local function head_pane(worktree, path, content) + return { + buf = content and git_show_buf(worktree, "HEAD", path) or empty_buf(), + name = "git://HEAD/" .. path, + } +end + +---@param worktree string +---@param path string +---@param exists boolean +---@return ow.Git.DiffSide +local function worktree_pane(worktree, path, exists) + if exists then + return { + buf = load_file_buf(vim.fs.joinpath(worktree, path)), + name = nil, + } + end + return { buf = empty_buf(), name = "git://worktree/" .. path } +end + +---@param s ow.Git.StatusState +---@param entry ow.Git.FileEntry +---@return ow.Git.DiffSide +local function index_pane(s, entry) + local in_index = not ( + entry.section == "Untracked" + or (entry.section == "Staged" and entry.x == "D") + ) + return { + buf = in_index and git_show_buf(s.worktree, "", entry.path, true) + or empty_buf(), + name = "git://index/" .. entry.path, + } +end + +---@param s ow.Git.StatusState +---@param entry ow.Git.FileEntry +---@return ow.Git.DiffSide? +local function other_pane(s, entry) + local p = entry.path + local worktree = s.worktree + if entry.section == "Staged" then + if entry.x == "A" then + return head_pane(worktree, p, false) + end + if entry.x == "D" then + return head_pane(worktree, p, true) + end + -- HEAD holds the pre-rename path + return head_pane(worktree, entry.orig or p, true) + end + if entry.section == "Unstaged" then + return worktree_pane(worktree, p, entry.y ~= "D") + end + if entry.section == "Untracked" then + return worktree_pane(worktree, p, true) + end +end + +---@param s ow.Git.StatusState +---@param entry ow.Git.StatusEntry +---@return ow.Git.DiffPair? +local function compute_pair(s, entry) + if not entry.path then + return nil + end + ---@cast entry ow.Git.FileEntry + local other = other_pane(s, entry) + if not other then + return nil + end + return { left = index_pane(s, entry), right = other } +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 sidebar_win integer +---@return integer? +local function find_default_main_win(sidebar_win) + local non_sidebar = {} + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if win ~= sidebar_win then + table.insert(non_sidebar, win) + end + end + if #non_sidebar ~= 1 then + return nil + end + local buf = vim.api.nvim_win_get_buf(non_sidebar[1]) + if + vim.api.nvim_buf_get_name(buf) == "" + and vim.bo[buf].buftype == "" + and not vim.bo[buf].modified + and vim.api.nvim_buf_line_count(buf) == 1 + and vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] == "" + then + return non_sidebar[1] + end +end + +---@param win integer +---@param enabled boolean +local function set_diff(win, enabled) + vim.api.nvim_win_call(win, function() + vim.cmd(enabled and "diffthis" or "diffoff") + end) + if enabled then + vim.wo[win].foldenable = true + vim.wo[win].foldlevel = 0 + end +end + +---@param s ow.Git.StatusState +---@param sidebar_win integer +---@return integer? left +---@return integer? right +local function adopt_diff_wins(s, sidebar_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 ~= sidebar_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.StatusEntry +---@return string +local function entry_key(entry) + return entry.section + .. "|" + .. (entry.path or entry.sha or "") + .. "|" + .. (entry.orig or "") +end + +---@param s ow.Git.StatusState +---@param entry ow.Git.StatusEntry +---@param focus_left boolean +local function show_diff(s, entry, focus_left) + local sidebar_win = find_sidebar() + if not sidebar_win then + return + end + + local left_win, right_win = adopt_diff_wins(s, sidebar_win) + local key = entry_key(entry) + + if s.last_shown_key == key and left_win and right_win then + if focus_left then + vim.api.nvim_set_current_win(left_win) + else + vim.api.nvim_set_current_win(sidebar_win) + end + return + end + + local pair = compute_pair(s, entry) + if not pair then + return + end + + if left_win and not right_win then + vim.api.nvim_set_current_win(left_win) + vim.cmd("rightbelow vertical split") + right_win = vim.api.nvim_get_current_win() + reset_diff_win(right_win) + elseif right_win and not left_win then + vim.api.nvim_set_current_win(right_win) + vim.cmd("leftabove vertical split") + left_win = vim.api.nvim_get_current_win() + reset_diff_win(left_win) + elseif not (left_win or right_win) then + local default_main = find_default_main_win(sidebar_win) + if default_main then + right_win = default_main + reset_diff_win(right_win) + vim.api.nvim_set_current_win(default_main) + vim.cmd("leftabove vertical split") + left_win = vim.api.nvim_get_current_win() + reset_diff_win(left_win) + else + -- No reusable default-empty window. Open the diff pair by + -- splitting from the sidebar. winfixwidth keeps the sidebar at 50 + -- when there are other windows to absorb the split; if the + -- sidebar is the only window in the tab, the split has to take + -- from the sidebar itself, so restore the width explicitly. + vim.api.nvim_set_current_win(sidebar_win) + vim.cmd("rightbelow vertical split") + right_win = vim.api.nvim_get_current_win() + reset_diff_win(right_win) + vim.cmd("leftabove vertical split") + left_win = vim.api.nvim_get_current_win() + reset_diff_win(left_win) + vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH) + end + 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 + + assert(left_win and right_win, "diff windows must be set") + 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 + + vim.api.nvim_win_set_buf(left_win, pair.left.buf) + vim.api.nvim_win_set_buf(right_win, pair.right.buf) + for _, side in ipairs({ pair.left, pair.right }) do + if side.name then + pcall(vim.api.nvim_buf_set_name, side.buf, side.name) + local ft = vim.filetype.match({ buf = side.buf }) + if ft then + vim.bo[side.buf].filetype = ft + end + end + end + set_diff(left_win, true) + set_diff(right_win, true) + s.last_shown_key = key + + if focus_left then + vim.api.nvim_set_current_win(left_win) + else + vim.api.nvim_set_current_win(sidebar_win) + end +end + +---@param focus_left boolean +local function preview_or_open(focus_left) + local s, entry = current_entry(vim.api.nvim_get_current_buf()) + if not s or not entry then + return + end + show_diff(s, entry, focus_left) +end + +local function action_stage() + local s, entry = current_entry(vim.api.nvim_get_current_buf()) + if not s or not entry or not entry.path then + return + end + ---@cast entry ow.Git.FileEntry + if entry.section == "Staged" then + return + end + vim.system({ "git", "add", "--", entry.path }, { cwd = s.worktree }) +end + +local function action_unstage() + local s, entry = current_entry(vim.api.nvim_get_current_buf()) + if not s or not entry or not entry.path then + return + end + ---@cast entry ow.Git.FileEntry + if entry.section ~= "Staged" then + return + end + local cmd = { "git", "restore", "--staged", "--" } + if entry.orig then + table.insert(cmd, entry.orig) + end + table.insert(cmd, entry.path) + vim.system(cmd, { cwd = s.worktree }) +end + +local function action_discard() + local s, entry = current_entry(vim.api.nvim_get_current_buf()) + if not s or not entry or not entry.path then + return + end + ---@cast entry ow.Git.FileEntry + if entry.section == "Staged" then + log.warning("file has staged changes; unstage first with 'u'") + return + end + + local prompt, action + if entry.section == "Untracked" then + prompt = string.format("Delete untracked file %s?", entry.path) + action = function() + os.remove(vim.fs.joinpath(s.worktree, entry.path)) + refresh(vim.api.nvim_get_current_buf()) + end + elseif entry.section == "Unstaged" then + prompt = string.format("Discard changes to %s?", entry.path) + action = function() + vim.system( + { "git", "checkout", "--", entry.path }, + { cwd = s.worktree } + ) + end + else + return + end + + if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then + action() + end +end + +local function action_help() + print(table.concat({ + "git status sidebar", + " 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")) +end + +---@param worktree string +local function open(worktree) + local existing = find_sidebar() + if existing then + vim.api.nvim_set_current_win(existing) + return + end + + local gitdir = repo.resolve(worktree) + if not gitdir then + return + end + + local previous_win = vim.api.nvim_get_current_win() + vim.cmd("leftabove vertical new") + local win = vim.api.nvim_get_current_win() + local bufnr = vim.api.nvim_get_current_buf() + + vim.bo[bufnr].buftype = "nofile" + vim.bo[bufnr].bufhidden = "wipe" + vim.bo[bufnr].swapfile = false + vim.bo[bufnr].filetype = "gitstatus" + vim.bo[bufnr].modifiable = false + + 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, SIDEBAR_WIDTH) + + state[bufnr] = { gitdir = gitdir, worktree = worktree, lines = {} } + + local function k(lhs, rhs, desc) + vim.keymap.set( + "n", + lhs, + rhs, + { buffer = bufnr, silent = true, desc = desc } + ) + end + k("", function() + preview_or_open(false) + end, "Preview diff") + k("", function() + preview_or_open(true) + end, "Open diff") + k("s", action_stage, "Stage file") + k("u", action_unstage, "Unstage file") + k("X", action_discard, "Discard worktree changes") + k("g?", action_help, "Help") + + state[bufnr].user_aucmd = vim.api.nvim_create_autocmd("User", { + pattern = "GitRefresh", + group = group, + callback = function(args) + if args.data and args.data.gitdir == gitdir then + refresh(bufnr) + end + 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.user_aucmd then + pcall(vim.api.nvim_del_autocmd, s.user_aucmd) + end + state[bufnr] = nil + end, + }) + + vim.api.nvim_set_current_win(previous_win) + refresh(bufnr) +end + +function M.toggle() + local sidebar_win = find_sidebar() + if sidebar_win then + vim.api.nvim_win_close(sidebar_win, false) + return + end + local path = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()) + if path == "" then + path = vim.fn.getcwd() + end + local _, worktree = repo.resolve(path) + if not worktree then + log.warning("not in a git repository") + return + end + open(worktree) +end + +return M diff --git a/plugins/nvim-tree.lua b/plugins/nvim-tree.lua index 33e3c4f..d7b2c64 100644 --- a/plugins/nvim-tree.lua +++ b/plugins/nvim-tree.lua @@ -111,8 +111,8 @@ require("nvim-tree").setup({ full_name = true, root_folder_label = function(path) local label = vim.fn.fnamemodify(path, ":~") - local git_head = vim.fn.FugitiveHead() - if git_head ~= "" then + local git_head = require("git").head(path) + if git_head then label = label .. ("  %s"):format(git_head) end return label diff --git a/plugins/onedark.lua b/plugins/onedark.lua index 4081e8d..0087ff4 100644 --- a/plugins/onedark.lua +++ b/plugins/onedark.lua @@ -50,9 +50,9 @@ local highlights = { DiffAdd = { bg = "#1a2f22" }, DiffChange = { bg = "#15304a" }, DiffDelete = { bg = "#311c1e" }, - GitDeleted = { fg = c.red }, - GitDirty = { fg = c.yellow }, - GitNew = { fg = c.green }, + -- GitDeleted = { fg = c.red }, + -- GitUnstaged = { fg = c.yellow }, + -- GitUntracked = { fg = c.green }, } for kind, color in pairs(completion_kind_colors) do highlights["LspKind" .. kind] = { fg = color } diff --git a/plugins/vim-fugitive.lua b/plugins/vim-fugitive.lua index 3fb83f3..78f07ba 100644 --- a/plugins/vim-fugitive.lua +++ b/plugins/vim-fugitive.lua @@ -1,35 +1,3 @@ -local function open_git_status() - local previous_win = vim.api.nvim_get_current_win() - vim.cmd("leftabove vertical G") - vim.api.nvim_win_set_width(0, 50) - vim.api.nvim_set_option_value("winfixwidth", true, { scope = "local" }) - vim.api.nvim_set_current_win(previous_win) -end - -local function get_git_status_win() - local current_tabpage = vim.api.nvim_get_current_tabpage() - for _, win in ipairs(vim.api.nvim_tabpage_list_wins(current_tabpage)) do - local buf = vim.api.nvim_win_get_buf(win) - local buftype = vim.api.nvim_get_option_value("buftype", { buf = buf }) - if - buftype == "nowrite" - and vim.api.nvim_buf_get_name(buf):match("^fugitive://.*%.git//$") - then - return win - end - end -end - -local function toggle_git_status() - local win = get_git_status_win() - if win then - vim.api.nvim_win_close(win, false) - return - end - - open_git_status() -end - vim.api.nvim_create_user_command("Glog", function(opts) local mods = opts.mods ~= "" and (opts.mods .. " ") or "" vim.cmd( @@ -58,7 +26,6 @@ end) vim.keymap.set("n", "gp", function() vim.cmd.G("push") end) -vim.keymap.set("n", "gg", toggle_git_status) vim.api.nvim_create_autocmd("User", { pattern = "GitRefresh", diff --git a/syntax/gitstatus.vim b/syntax/gitstatus.vim new file mode 100644 index 0000000..bea8b08 --- /dev/null +++ b/syntax/gitstatus.vim @@ -0,0 +1,44 @@ +if exists("b:current_syntax") + finish +endif + +syntax match gitstatusLabel /\v^(Head|Push)\ze:/ +syntax match gitstatusBranch /\v(^(Head|Push):\s+)@<=\S+/ +syntax match gitstatusAhead /\v\+\d+/ +syntax match gitstatusBehind /\v-\d+/ + +syntax region gitstatusUntrackedHeader start=/\v^Untracked>/ end=/\v^$/ +syntax region gitstatusUnstagedHeader start=/\v^Unstaged>/ end=/\v^$/ +syntax region gitstatusStagedHeader start=/\v^Staged>/ end=/\v^$/ +syntax region gitstatusUnmergedHeader start=/\v^Unmerged>/ end=/\v^$/ +syntax region gitstatusUnpushedHeader start=/\v^Unpushed>/ end=/\v^$/ +syntax region gitstatusUnpulledHeader start=/\v^Unpulled>/ end=/\v^$/ + +syntax match gitstatusUntrackedLabel /\v^Untracked/ contained containedin=gitstatusUntrackedHeader +syntax match gitstatusUnstagedLabel /\v^Unstaged/ contained containedin=gitstatusUnstagedHeader +syntax match gitstatusStagedLabel /\v^Staged/ contained containedin=gitstatusStagedHeader +syntax match gitstatusUnmergedLabel /\v^Unmerged/ contained containedin=gitstatusUnmergedHeader +syntax match gitstatusUnpushedLabel /\v^Unpushed/ contained containedin=gitstatusUnpushedHeader +syntax match gitstatusUnpulledLabel /\v^Unpulled/ contained containedin=gitstatusUnpulledHeader + +syntax match gitstatusHeaderCount /\v\(\zs\d+\ze\)/ contained containedin=gitstatusUntrackedHeader, + \ gitstatusUnstagedHeader, + \ gitstatusStagedHeader, + \ gitstatusUnmergedHeader, + \ gitstatusUnpushedHeader, + \ gitstatusUnpulledHeader + +highlight default link gitstatusLabel Label +highlight default link gitstatusBranch None +highlight default link gitstatusAhead GitUnpushed +highlight default link gitstatusBehind GitUnpulled +highlight default link gitstatusHeaderCount Number + +highlight default link gitstatusUntrackedLabel gitstatusLabel +highlight default link gitstatusUnstagedLabel gitstatusLabel +highlight default link gitstatusStagedLabel gitstatusLabel +highlight default link gitstatusUnmergedLabel gitstatusLabel +highlight default link gitstatusUnpushedLabel gitstatusLabel +highlight default link gitstatusUnpulledLabel gitstatusLabel + +let b:current_syntax = "gitstatus"