From 80d6d465cf854b57b0360c7acb0a49408e9fcd3c Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Wed, 6 May 2026 00:45:56 +0200 Subject: [PATCH] refactor(git): rework module around clearer Status and Repo split --- lua/core/keymap.lua | 2 +- lua/core/options.lua | 2 +- lua/git/cmd.lua | 16 +- lua/git/commit.lua | 2 +- lua/git/diff.lua | 18 +- lua/git/init.lua | 47 +- lua/git/log.lua | 18 +- lua/git/object.lua | 20 +- lua/git/repo.lua | 535 ++++++++++------------- lua/git/status.lua | 188 ++++++-- lua/git/{sidebar.lua => status_view.lua} | 474 +++++++------------- lua/git/statusline.lua | 31 ++ lua/git/util.lua | 48 +- plugins/nvim-tree.lua | 103 +++-- plugins/onedark.lua | 4 +- syntax/gitsidebar.vim | 44 -- syntax/gitstatus.vim | 44 ++ 17 files changed, 821 insertions(+), 775 deletions(-) rename lua/git/{sidebar.lua => status_view.lua} (54%) create mode 100644 lua/git/statusline.lua delete mode 100644 syntax/gitsidebar.vim create mode 100644 syntax/gitstatus.vim diff --git a/lua/core/keymap.lua b/lua/core/keymap.lua index eb3df1e..393ccdd 100644 --- a/lua/core/keymap.lua +++ b/lua/core/keymap.lua @@ -242,7 +242,7 @@ vim.keymap.set("n", "gH", function() require("git.diff").split({ rev = "HEAD", vertical = false }) end) vim.keymap.set("n", "gg", function() - require("git.sidebar").toggle() + require("git.status_view").toggle() end) vim.keymap.set("n", "gc", function() require("git.commit").commit() diff --git a/lua/core/options.lua b/lua/core/options.lua index 196993c..3573a26 100644 --- a/lua/core/options.lua +++ b/lua/core/options.lua @@ -77,7 +77,7 @@ vim.opt.inccommand = "split" vim.opt.winborder = "rounded" vim.opt.confirm = true -vim.opt.statusline = "%{expand('%:.')} %{%v:lua.require('git.status').statusline()%} %3(%m%)" +vim.opt.statusline = "%{expand('%:.')} %{%v:lua.require('git.statusline').render()%} %3(%m%)" .. " %=" .. " %{%v:lua.vim.diagnostic.status()%}" .. " %{&filetype} %{&fileencoding} %{&fileformat}" diff --git a/lua/git/cmd.lua b/lua/git/cmd.lua index b7a3548..2f7720f 100644 --- a/lua/git/cmd.lua +++ b/lua/git/cmd.lua @@ -5,11 +5,11 @@ local util = require("git.util") local M = {} ----@class ow.Git.SplitHandler +---@class ow.Git.Cmd.SplitHandler ---@field ft string ---@field needs_rev boolean? ----@type table +---@type table local SPLIT_HANDLERS = { log = { ft = "git" }, diff = { ft = "diff" }, @@ -74,7 +74,7 @@ end ---@param r ow.Git.Repo ---@param args string[] ----@param conf ow.Git.SplitHandler +---@param conf ow.Git.Cmd.SplitHandler local function run_in_split(r, args, conf) local cmd = { "git" } vim.list_extend(cmd, args) @@ -86,7 +86,7 @@ local function run_in_split(r, args, conf) end local name = "[git " .. table.concat(args, " ") .. "]" local buf = place_split(name) - repo.attach(buf, r) + repo.bind(buf, r) object.attach_dispatch(buf) local state = r:state(buf) --[[@as -nil]] state.sha = nil @@ -178,7 +178,7 @@ end ---@param args string[] function M.run(args) - local r = repo.find() + local r = repo.resolve() if not r then util.warning("not in a git repository") return @@ -193,7 +193,7 @@ function M.run(args) if sub == "show" then local arg = first_positional(args, 2) if arg and arg:find(":", 1, true) then - object.open_object(r, arg) + object.open(r, arg) return end run_in_split(r, args, { ft = "git", needs_rev = true }) @@ -204,7 +204,7 @@ function M.run(args) if vim.list_contains(args, "-p") then local rev = first_positional(args, 2) if rev then - object.open_object(r, rev) + object.open(r, rev) return end end @@ -223,7 +223,7 @@ end ---@param arg_lead string ---@return string[] function M.complete_rev(arg_lead) - local r = repo.find() + local r = repo.resolve() if not r then return {} end diff --git a/lua/git/commit.lua b/lua/git/commit.lua index b2b119d..c929c30 100644 --- a/lua/git/commit.lua +++ b/lua/git/commit.lua @@ -7,7 +7,7 @@ local M = {} ---@param opts { amend: boolean? }? function M.commit(opts) local amend = opts and opts.amend or false - local r = repo.find() + local r = repo.resolve() if not r then util.warning("not in a git repository") return diff --git a/lua/git/diff.lua b/lua/git/diff.lua index 7904ce8..b0e9889 100644 --- a/lua/git/diff.lua +++ b/lua/git/diff.lua @@ -4,6 +4,14 @@ local util = require("git.util") local M = {} +---@class ow.Git.Diff.Side +---@field buf integer +---@field name string? + +---@class ow.Git.Diff.Pair +---@field left ow.Git.Diff.Side +---@field right ow.Git.Diff.Side + ---@param win integer ---@param enabled boolean function M.set_diff(win, enabled) @@ -28,7 +36,7 @@ end ---@param left_win integer ---@param right_win integer ----@param pair ow.Git.DiffPair +---@param pair ow.Git.Diff.Pair function M.update_pair(left_win, right_win, pair) M.set_diff(left_win, false) M.set_diff(right_win, false) @@ -58,11 +66,11 @@ local function place_pair(buf_a, buf_b, a_left, vertical) end end ----@param opts ow.Git.SplitOpts +---@param opts ow.Git.Diff.SplitOpts ---@param buf integer ---@param rev ow.Git.Revision local function uri_split(opts, buf, rev) - local r = repo.find(buf) + local r = repo.resolve(buf) if not r then util.warning("git URI buffer has no worktree") return @@ -127,11 +135,11 @@ local function uri_split(opts, buf, rev) place_pair(buf, object.buf_for(r, other_rev, content), left, opts.vertical) end ----@class ow.Git.SplitOpts +---@class ow.Git.Diff.SplitOpts ---@field rev string? ---@field vertical boolean ----@param opts ow.Git.SplitOpts +---@param opts ow.Git.Diff.SplitOpts function M.split(opts) local cur_buf = vim.api.nvim_get_current_buf() local cur_path = vim.api.nvim_buf_get_name(cur_buf) diff --git a/lua/git/init.lua b/lua/git/init.lua index c362171..9f6f794 100644 --- a/lua/git/init.lua +++ b/lua/git/init.lua @@ -20,25 +20,28 @@ function M.init() 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) - require("git.repo").refresh(args.buf) - end, - } - ) + vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, { + group = group, + callback = function(args) + require("git.repo").track(args.buf) + end, + }) + vim.api.nvim_create_autocmd({ "BufWritePost", "FileChangedShellPost" }, { + group = group, + callback = function(args) + require("git.repo").refresh(args.buf) + end, + }) vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, { group = group, callback = function(args) - require("git.repo").unregister(args.buf) + require("git.repo").unbind(args.buf) end, }) vim.api.nvim_create_autocmd("FocusGained", { group = group, - callback = function(args) - require("git.repo").refresh(args.buf) + callback = function() + require("git.repo").refresh_all() end, }) vim.api.nvim_create_autocmd("VimLeavePre", { @@ -48,6 +51,22 @@ function M.init() end, }) + vim.api.nvim_create_autocmd({ "VimEnter", "DirChanged", "TabEnter" }, { + group = group, + callback = function() + require("git.repo").update_cwd_repo() + end, + }) + vim.api.nvim_create_autocmd("TabClosed", { + group = group, + callback = function(args) + local tab = tonumber(args.file) --[[@as integer?]] + if tab then + require("git.repo").release_tab(tab) + end + end, + }) + vim.api.nvim_create_autocmd("BufReadCmd", { pattern = "git://*", group = group, @@ -118,6 +137,10 @@ function M.init() return require("git.cmd").complete(...) end, }) + + vim.api.nvim_create_user_command("Grefresh", function() + require("git.repo").refresh_all() + end, { desc = "Refresh git status for all repos" }) end return M diff --git a/lua/git/log.lua b/lua/git/log.lua index ab592d8..1bd39a5 100644 --- a/lua/git/log.lua +++ b/lua/git/log.lua @@ -12,7 +12,7 @@ local cr = vim.api.nvim_replace_termcodes("", true, false, true) ---@param buf integer local function attach_dispatch(buf) vim.keymap.set("n", "", function() - local r = repo.find(buf) + local r = repo.resolve(buf) -- Anchor past the leading graph chars (matches the leading sha -- column, not any hex word that happens to appear later in the -- subject). @@ -22,7 +22,7 @@ local function attach_dispatch(buf) :match("^[*|/\\_ ]*(%x%x%x%x%x%x%x+)") if sha then ---@cast r -nil - require("git.object").open_object(r, sha, { split = false }) + require("git.object").open(r, sha, { split = false }) else vim.api.nvim_feedkeys(cr, "n", false) end @@ -50,7 +50,7 @@ end ---@param buf integer local function populate(buf) - local r = repo.find(buf) + local r = repo.resolve(buf) local state = r and r:state(buf) if not r or not state then return @@ -100,7 +100,7 @@ function M.read_uri(buf) if not r then return end - repo.attach(buf, r) + repo.bind(buf, r) vim.bo[buf].swapfile = false vim.bo[buf].bufhidden = "hide" @@ -113,7 +113,7 @@ function M.read_uri(buf) populate(buf) end ----@class ow.Git.LogOpts +---@class ow.Git.Log.OpenOpts ---@field max_count integer? ---@type table @@ -121,17 +121,17 @@ M.opt_parsers = { max_count = tonumber, } ----@param opts ow.Git.LogOpts? +---@param opts ow.Git.Log.OpenOpts? function M.open(opts) opts = opts or {} - local r = repo.find() + local r = repo.resolve() if not r then util.warning("not in a git repository") return end local buf = vim.fn.bufadd(M.URI_PREFIX .. r.worktree) - repo.attach(buf, r) + repo.bind(buf, r) local state = r:state(buf) --[[@as -nil]] state.log_max_count = opts.max_count local was_loaded = vim.api.nvim_buf_is_loaded(buf) @@ -183,4 +183,6 @@ function M.complete_glog(arg_lead) return matches end +repo.on_uri_refresh(M.URI_PREFIX, M.read_uri) + return M diff --git a/lua/git/object.lua b/lua/git/object.lua index c5e05f2..b9371fa 100644 --- a/lua/git/object.lua +++ b/lua/git/object.lua @@ -145,7 +145,7 @@ end ---@return integer function M.buf_for(r, rev, content) local buf = vim.fn.bufadd(M.format_uri(rev)) - repo.attach(buf, r) + repo.bind(buf, r) if content then local state = r:state(buf) --[[@as -nil]] state.pending_content = content @@ -163,12 +163,12 @@ function M.read_uri(buf) end local rev_str = rev:format() - local r = repo.find(buf) + local r = repo.resolve(buf) if not r then util.error("git BufReadCmd %s: cannot resolve worktree", name) return end - repo.attach(buf, r) + repo.bind(buf, r) local state = r:state(buf) --[[@as -nil]] vim.bo[buf].swapfile = false @@ -275,7 +275,7 @@ local function load_blob(r, blob, path, sha) vim.api.nvim_set_current_buf(buf) end ----@param s ow.Git.BufState +---@param s ow.Git.Repo.BufState ---@param section ow.Git.DiffSection local function open_section(s, section) if not section.blob_a or not section.blob_b then @@ -300,13 +300,13 @@ local function open_section(s, section) vim.api.nvim_set_current_buf(buf) end ----@class ow.Git.OpenObjectOpts +---@class ow.Git.Object.OpenOpts ---@field split (false|"above"|"below"|"left"|"right")? ---@param r ow.Git.Repo ---@param rev string ----@param opts ow.Git.OpenObjectOpts? -function M.open_object(r, rev, opts) +---@param opts ow.Git.Object.OpenOpts? +function M.open(r, rev, opts) local parsed = Revision.parse(rev) if parsed.base then local sha = r:rev_parse(parsed.base, true) @@ -341,7 +341,7 @@ function M.open_under_cursor() or line:match("^tree (%x+)$") or line:match("^object (%x+)$") if sha then - M.open_object(r, sha, { split = false }) + M.open(r, sha, { split = false }) return true end @@ -351,7 +351,7 @@ function M.open_under_cursor() local nav_rev = entry_type == "blob" and Revision.new({ base = s.sha, path = entry_name }):format() or entry_sha - M.open_object(r, nav_rev, { split = false }) + M.open(r, nav_rev, { split = false }) return true end @@ -384,4 +384,6 @@ function M.open_under_cursor() return false end +repo.on_uri_refresh(M.URI_PREFIX, M.read_uri) + return M diff --git a/lua/git/repo.lua b/lua/git/repo.lua index cd38afe..db3b7d9 100644 --- a/lua/git/repo.lua +++ b/lua/git/repo.lua @@ -1,7 +1,9 @@ local status = require("git.status") local util = require("git.util") ----@param buf integer? +local M = {} + +---@param buf? integer ---@return integer local function expand_buf(buf) if not buf or buf == 0 then @@ -10,7 +12,7 @@ local function expand_buf(buf) return buf end ----@class ow.Git.BufState +---@class ow.Git.Repo.BufState ---@field repo ow.Git.Repo ---@field sha string? ---@field parent_sha string? @@ -19,110 +21,58 @@ end ---@field log_max_count integer? ---@field pending_content string? ----@class ow.Git.StatusEntry ----@field section "Untracked"|"Unstaged"|"Staged"|"Unmerged" ----@field x string ----@field y string ----@field path string ----@field orig string? +---@alias ow.Git.Repo.Event "refresh" ----@class ow.Git.BranchInfo ----@field head string? ----@field upstream string? ----@field ahead integer ----@field behind integer - ----@class ow.Git.PorcelainGroups ----@field Untracked ow.Git.StatusEntry[] ----@field Unstaged ow.Git.StatusEntry[] ----@field Staged ow.Git.StatusEntry[] ----@field Unmerged ow.Git.StatusEntry[] +local global = util.Emitter.new() ---@class ow.Git.Repo ---@field gitdir string ---@field worktree string ----@field buffers table ----@field watcher? uv.uv_fs_event_t ----@field refresh fun(self: ow.Git.Repo) ----@field refresh_handle ow.Git.Util.DebounceHandle ----@field private refresh_listeners (fun(r: ow.Git.Repo, porcelain_stdout: string?))[] +---@field buffers table +---@field tabs table +---@field status ow.Git.Status +---@field private _events ow.Git.Util.Emitter +---@field private _watcher? uv.uv_fs_event_t +---@field private _schedule_refresh fun(self: ow.Git.Repo) +---@field private _refresh_handle ow.Git.Util.DebounceHandle local Repo = {} Repo.__index = Repo ----@param line string ----@return string x, string y, string path, string? orig -local function parse_porcelain_line(line) - local x = line:sub(1, 1) - local y = line:sub(2, 2) - local rest = line:sub(4) - local orig - if x == "R" or x == "C" or y == "R" or y == "C" then - local arrow = rest:find(" -> ", 1, true) - if arrow then - orig = rest:sub(1, arrow - 1) - rest = rest:sub(arrow + 4) - end - end - return x, y, rest, orig -end +local STATUS_CMD = { + "git", + "--no-optional-locks", + "-c", + "core.quotePath=false", + "status", + "--porcelain=v1", + "--branch", + "--ignored", +} ----@param r ow.Git.Repo -local function do_refresh(r) +---@private +function Repo:_fetch_status() vim.system( - { - "git", - "-c", - "core.quotePath=false", - "status", - "--porcelain=v1", - "--branch", - }, - { cwd = r.worktree, text = true }, + STATUS_CMD, + { cwd = self.worktree, text = true }, vim.schedule_wrap(function(obj) - local statuses = {} - if obj.code == 0 then - for line in (obj.stdout or ""):gmatch("[^\r\n]+") do - if line:sub(1, 2) ~= "##" then - local x, y, path = parse_porcelain_line(line) - statuses[vim.fs.joinpath(r.worktree, path)] = - status.format(x .. y) - end - end - else + if obj.code ~= 0 then util.warning( "git status failed: %s", vim.trim(obj.stderr or "") ) + return end - local dirty = false - for buf in pairs(r.buffers) do - if not vim.api.nvim_buf_is_valid(buf) then - r.buffers[buf] = nil - else - local name = vim.api.nvim_buf_get_name(buf) - local object = require("git.object") - local log = require("git.log") - if name:sub(1, #object.URI_PREFIX) == object.URI_PREFIX then - object.read_uri(buf) - elseif name:sub(1, #log.URI_PREFIX) == log.URI_PREFIX then - log.read_uri(buf) - else - local s = statuses[vim.fn.resolve(name)] - if vim.b[buf].git_status ~= s then - vim.b[buf].git_status = s - dirty = true - end - end - end - end - if dirty then - vim.cmd.redrawstatus({ bang = true }) - end - r:notify_refresh(obj.code == 0 and obj.stdout or nil) + self.status = status.parse(obj.stdout or "") + self._events:emit("refresh", self.status) + global:emit("refresh", self, self.status) end) ) end +function Repo:refresh() + self:_schedule_refresh() +end + ---@param gitdir string ---@param worktree string ---@return ow.Git.Repo @@ -131,10 +81,14 @@ function Repo.new(gitdir, worktree) gitdir = gitdir, worktree = worktree, buffers = {}, - refresh_listeners = {}, + tabs = {}, + status = status.parse(""), + _events = util.Emitter.new(), }, Repo) - self.refresh, self.refresh_handle = util.debounce(do_refresh, 50) + self._schedule_refresh, self._refresh_handle = + util.debounce(Repo._fetch_status, 50) self:start_watcher() + self:refresh() return self end @@ -148,7 +102,7 @@ function Repo:start_watcher() ) return end - local ok, err = watcher:start( + local ok, start_err = watcher:start( self.gitdir, { recursive = true }, function(err_, filename) @@ -163,67 +117,35 @@ function Repo:start_watcher() end ) if not ok then - util.warning("failed to watch %s: %s", self.gitdir, tostring(err)) + util.warning("failed to watch %s: %s", self.gitdir, tostring(start_err)) watcher:close() return end - self.watcher = watcher + self._watcher = watcher end -function Repo:stop_watcher() - if self.watcher then - self.watcher:stop() - self.watcher:close() - self.watcher = nil +function Repo:close() + if self._watcher then + self._watcher:stop() + self._watcher:close() + self._watcher = nil end - self.refresh_handle.close() + self._refresh_handle.close() end ----@param buf integer -function Repo:add_buffer(buf) - buf = expand_buf(buf) - if not self.buffers[buf] then - self.buffers[buf] = { repo = self } - end +---@param event ow.Git.Repo.Event +---@param fn fun(...) +---@return fun() unsubscribe +function Repo:on(event, fn) + return self._events:on(event, fn) end ----@param buf integer -function Repo:remove_buffer(buf) - self.buffers[expand_buf(buf)] = nil -end - ----@param buf integer ----@return ow.Git.BufState? +---@param buf? integer +---@return ow.Git.Repo.BufState? function Repo:state(buf) return self.buffers[expand_buf(buf)] end ----@return boolean -function Repo:has_buffers() - return next(self.buffers) ~= nil -end - ----@param fn fun(r: ow.Git.Repo, porcelain_stdout: string?) ----@return fun() unsubscribe -function Repo:on_refresh(fn) - table.insert(self.refresh_listeners, fn) - return function() - for i, f in ipairs(self.refresh_listeners) do - if f == fn then - table.remove(self.refresh_listeners, i) - return - end - end - end -end - ----@param porcelain_stdout string? -function Repo:notify_refresh(porcelain_stdout) - for _, fn in ipairs(self.refresh_listeners) do - fn(self, porcelain_stdout) - end -end - ---@return string? function Repo:head() local f = io.open(vim.fs.joinpath(self.gitdir, "HEAD"), "r") @@ -278,27 +200,117 @@ function Repo:rev_parse(rev, short) return trimmed ~= "" and trimmed or nil end -local M = {} +---@type table keyed by worktree +local repos = {} ----@type table -local repo_by_gitdir = {} +---@param event ow.Git.Repo.Event +---@param fn fun(...) +---@return fun() unsubscribe +function M.on(event, fn) + return global:on(event, fn) +end ----@type table -local repo_by_buf = {} +---@param prefix string +---@param fn fun(buf: integer) +---@return fun() unsubscribe +function M.on_uri_refresh(prefix, fn) + return M.on("refresh", function(r) + for buf in pairs(r.buffers) do + if vim.api.nvim_buf_is_valid(buf) then + local name = vim.api.nvim_buf_get_name(buf) + if name:sub(1, #prefix) == prefix then + fn(buf) + end + end + end + end) +end + +---@param r ow.Git.Repo +local function release(r) + if repos[r.worktree] ~= r then + return + end + if next(r.buffers) ~= nil or next(r.tabs) ~= nil then + return + end + r:close() + repos[r.worktree] = nil +end + +---@param buf integer +---@return ow.Git.Repo? +local function find_by_buf(buf) + for _, r in pairs(repos) do + if r.buffers[buf] then + return r + end + end + return nil +end ---@param path string ---@return ow.Git.Repo? -function M.resolve(path) - path = vim.fn.resolve(path) +local function find_by_path(path) + if path == "" then + return nil + end + if repos[path] then + return repos[path] + end + for wt, r in pairs(repos) do + if path:sub(1, #wt + 1) == wt .. "/" then + return r + end + end + return nil +end + +---@param buf integer +---@return string +local function path_for_buf(buf) + local path = vim.api.nvim_buf_get_name(buf) + if path == "" or path:match("^%a+://") then + return vim.fn.getcwd() + end + return vim.fn.resolve(path) +end + +---@param arg? integer | string bufnr (default current) or worktree path +---@return ow.Git.Repo? +function M.find(arg) + if type(arg) == "string" then + return find_by_path(arg) + end + local buf = expand_buf(arg) + return find_by_buf(buf) or find_by_path(path_for_buf(buf)) +end + +---@param arg? integer | string bufnr (default current) or worktree path +---@return ow.Git.Repo? +function M.resolve(arg) + local existing = M.find(arg) + if existing then + return existing + end + local path + if type(arg) == "string" then + path = vim.fn.resolve(arg) + else + path = path_for_buf(expand_buf(arg)) + end local found = vim.fs.find(".git", { upward = true, path = path })[1] if not found then return nil end + local worktree = vim.fs.dirname(found) + if repos[worktree] then + return repos[worktree] + end local stat = vim.uv.fs_stat(found) if not stat then return nil end - local worktree = vim.fs.dirname(found) local gitdir if stat.type == "directory" then gitdir = found @@ -314,192 +326,123 @@ function M.resolve(path) util.warning(".git file at %s has no `gitdir:` line", found) return nil end - if rel:match("^/") then - gitdir = rel - else - gitdir = vim.fs.joinpath(worktree, rel) - end - gitdir = vim.fs.normalize(gitdir) - end - local r = repo_by_gitdir[gitdir] - if not r then - r = Repo.new(gitdir, worktree) - repo_by_gitdir[gitdir] = r + gitdir = vim.fs.normalize( + rel:match("^/") and rel or vim.fs.joinpath(worktree, rel) + ) end + local r = Repo.new(gitdir, worktree) + repos[worktree] = r return r end ----@param buf integer? ----@return ow.Git.Repo? -function M.find(buf) - buf = expand_buf(buf) - local existing = repo_by_buf[buf] - if existing then - return existing - end - local path = vim.api.nvim_buf_get_name(buf) - if path == "" or path:match("^%a+://") then - path = vim.fn.getcwd() - end - return M.resolve(path) -end - ----@param buf integer? ----@return ow.Git.BufState? +---@param buf? integer +---@return ow.Git.Repo.BufState? function M.state(buf) buf = expand_buf(buf) - local r = repo_by_buf[buf] + local r = find_by_buf(buf) return r and r.buffers[buf] end ----@param buf integer +---@param buf? integer ---@param r ow.Git.Repo -function M.attach(buf, r) +function M.bind(buf, r) buf = expand_buf(buf) - if repo_by_buf[buf] == r then + local prev = find_by_buf(buf) + if prev == r then return end - if repo_by_buf[buf] then - repo_by_buf[buf]:remove_buffer(buf) + if prev then + prev.buffers[buf] = nil + release(prev) end - r:add_buffer(buf) - repo_by_buf[buf] = r + r.buffers[buf] = { repo = r } end ----@param buf integer ----@return ow.Git.Repo? -function M.register(buf) +---@param buf? integer +function M.unbind(buf) buf = expand_buf(buf) - local r = M.find(buf) - if not r then - return nil - end - if repo_by_buf[buf] ~= r then - M.attach(buf, r) - end - return r -end - ----@param buf integer -function M.unregister(buf) - buf = expand_buf(buf) - local r = repo_by_buf[buf] + local r = find_by_buf(buf) if not r then return end - repo_by_buf[buf] = nil - r:remove_buffer(buf) - if not r:has_buffers() then - r:stop_watcher() - repo_by_gitdir[r.gitdir] = nil - end + r.buffers[buf] = nil + release(r) end ---@param buf integer -function M.refresh(buf) - buf = expand_buf(buf) +---@return boolean +local function is_worktree_buf(buf) if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].buftype ~= "" then - return + return false end local path = vim.api.nvim_buf_get_name(buf) - if path == "" or path:match("^%a+://") then + return path ~= "" and not path:match("^%a+://") +end + +---@param buf? integer +function M.track(buf) + buf = expand_buf(buf) + if not is_worktree_buf(buf) then return end - local r = M.register(buf) - if not r then - vim.b[buf].git_status = nil + local r = M.resolve(buf) + if r and not r.buffers[buf] then + M.bind(buf, r) + end +end + +---@param buf? integer +function M.refresh(buf) + local r = find_by_buf(expand_buf(buf)) + if r then + r:refresh() + end +end + +function M.refresh_all() + for _, r in pairs(repos) do + r:refresh() + end +end + +function M.update_cwd_repo() + local tab = vim.api.nvim_get_current_tabpage() + local new = M.resolve(vim.fn.getcwd()) + local old + for _, r in pairs(repos) do + if r.tabs[tab] then + old = r + break + end + end + if new == old then return end - r:refresh() + if old then + old.tabs[tab] = nil + release(old) + end + if new then + new.tabs[tab] = true + new:refresh() + end +end + +---@param tab integer +function M.release_tab(tab) + for _, r in pairs(repos) do + if r.tabs[tab] then + r.tabs[tab] = nil + release(r) + return + end + end end function M.stop_all() - for _, r in pairs(repo_by_gitdir) do - r:stop_watcher() + for _, r in pairs(repos) do + r:close() end end ----@param line string ----@return ow.Git.BranchInfo -function M.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 stdout string ----@return ow.Git.BranchInfo, ow.Git.PorcelainGroups -function M.parse_porcelain(stdout) - local branch = { ahead = 0, behind = 0 } - ---@type ow.Git.PorcelainGroups - local groups = { - Untracked = {}, - Unstaged = {}, - Staged = {}, - Unmerged = {}, - } - for line in stdout:gmatch("[^\r\n]+") do - if line:sub(1, 2) == "##" then - branch = M.parse_branch_line(line) - else - local x, y, path, orig = parse_porcelain_line(line) - if x == "?" and y == "?" then - table.insert(groups.Untracked, { - section = "Untracked", - x = x, - y = y, - path = path, - orig = orig, - }) - elseif status.UNMERGED[x .. y] then - table.insert(groups.Unmerged, { - section = "Unmerged", - x = x, - y = y, - path = path, - orig = orig, - }) - else - if x ~= " " then - table.insert(groups.Staged, { - section = "Staged", - x = x, - y = y, - path = path, - orig = orig, - }) - end - if y ~= " " then - table.insert(groups.Unstaged, { - section = "Unstaged", - x = x, - y = y, - path = path, - orig = orig, - }) - end - end - end - end - return branch, groups -end - return M diff --git a/lua/git/status.lua b/lua/git/status.lua index 7573874..9cd0d54 100644 --- a/lua/git/status.lua +++ b/lua/git/status.lua @@ -1,6 +1,6 @@ local M = {} -M.UNMERGED = { +local UNMERGED = { DD = true, AU = true, UD = true, @@ -10,48 +10,174 @@ M.UNMERGED = { UU = true, } ----@param code string ----@return string? char ----@return string? hl_group -function M.indicator(code) - if code == "" then - return nil +---@alias ow.Git.Status.EntryKind "untracked"|"unstaged"|"staged"|"unmerged"|"ignored" + +---@class ow.Git.Status.Entry +---@field path string +---@field kind ow.Git.Status.EntryKind +---@field char string +---@field hl string +---@field orig string? + +---@class ow.Git.Status.BranchInfo +---@field head string? +---@field upstream string? +---@field ahead integer +---@field behind integer + +---@class ow.Git.Status +---@field branch ow.Git.Status.BranchInfo +---@field entries table +local Status = {} +Status.__index = Status + +---@param kind ow.Git.Status.EntryKind +---@return ow.Git.Status.Entry[] +function Status:by_kind(kind) + local out = {} + for _, list in pairs(self.entries) do + for _, e in ipairs(list) do + if e.kind == kind then + table.insert(out, e) + end + end end - if code == "??" then - return "?", "GitUntracked" - end - if code == "!!" then - return "!", "GitIgnored" - end - if M.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 out +end + +---@param x string +---@return string char, string hl +local function staged_attrs(x) + if x == "R" then return "R", "GitRenamed" end - if y == " " and x ~= " " then - return x, "GitStaged" + return x, "GitStaged" +end + +---@param y string +---@return string char, string hl +local function unstaged_attrs(y) + if y == "R" then + return "R", "GitRenamed" end if y == "D" then return "D", "GitDeleted" end - return "M", "GitUnstaged" + return y, "GitUnstaged" end ----@param code string ----@return string? -function M.format(code) - local char, hl = M.indicator(code) - if not char then - return nil +---@param line string +---@return ow.Git.Status.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 - return string.format("%%#%s#%s%%*", hl, char) + 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 ----@return string -function M.statusline() - return vim.b.git_status or "" +---@param line string +---@return string x, string y, string path, string? orig +local function parse_status_line(line) + local x = line:sub(1, 1) + local y = line:sub(2, 2) + local rest = line:sub(4) + local orig + if x == "R" or x == "C" or y == "R" or y == "C" then + local arrow = rest:find(" -> ", 1, true) + if arrow then + orig = rest:sub(1, arrow - 1) + rest = rest:sub(arrow + 4) + end + end + return x, y, rest, orig +end + +---@param entries table +---@param entry ow.Git.Status.Entry +local function add(entries, entry) + local key = entry.path + if key:sub(-1) == "/" then + key = key:sub(1, -2) + end + local list = entries[key] or {} + table.insert(list, entry) + entries[key] = list +end + +---@param stdout string +---@return ow.Git.Status +function M.parse(stdout) + local branch = { ahead = 0, behind = 0 } + ---@type table + local entries = {} + for line in stdout:gmatch("[^\r\n]+") do + if line:sub(1, 2) == "##" then + branch = parse_branch_line(line) + else + local x, y, path, orig = parse_status_line(line) + if x == "?" and y == "?" then + add(entries, { + path = path, + kind = "untracked", + char = "?", + hl = "GitUntracked", + }) + elseif x == "!" and y == "!" then + add(entries, { + path = path, + kind = "ignored", + char = "i", + hl = "GitIgnored", + }) + elseif UNMERGED[x .. y] then + add(entries, { + path = path, + kind = "unmerged", + char = "!", + hl = "GitUnmerged", + }) + else + if x ~= " " then + local char, hl = staged_attrs(x) + add(entries, { + path = path, + kind = "staged", + char = char, + hl = hl, + orig = orig, + }) + end + if y ~= " " then + local char, hl = unstaged_attrs(y) + add(entries, { + path = path, + kind = "unstaged", + char = char, + hl = hl, + orig = orig, + }) + end + end + end + end + return setmetatable({ branch = branch, entries = entries }, Status) end return M diff --git a/lua/git/sidebar.lua b/lua/git/status_view.lua similarity index 54% rename from lua/git/sidebar.lua rename to lua/git/status_view.lua index 799dd81..ddd7d19 100644 --- a/lua/git/sidebar.lua +++ b/lua/git/status_view.lua @@ -2,32 +2,18 @@ local Revision = require("git.revision") local diff = require("git.diff") local object = require("git.object") local repo = require("git.repo") -local status = require("git.status") local util = require("git.util") local M = {} -local SECTIONS = { - "Untracked", - "Unstaged", - "Staged", - "Unmerged", - "Unpushed", - "Unpulled", -} -local SIDEBAR_WIDTH = 50 +---@type ow.Git.Status.EntryKind[] +local KINDS = { "untracked", "unstaged", "staged", "unmerged" } +local WINDOW_WIDTH = 50 ----@class ow.Git.CommitEntry ----@field section "Unpushed"|"Unpulled" ----@field sha string ----@field subject string? - ----@alias ow.Git.SidebarEntry ow.Git.StatusEntry | ow.Git.CommitEntry - ----@class ow.Git.SidebarState +---@class ow.Git.StatusView.State ---@field repo ow.Git.Repo ----@field lines table ----@field sidebar_win integer? +---@field lines table +---@field win integer? ---@field invocation_win integer? ---@field diff_left_win integer? ---@field diff_right_win integer? @@ -35,181 +21,56 @@ local SIDEBAR_WIDTH = 50 ---@field last_shown_key string? ---@field last_render_key string? ----@type table +---@type table local state = {} -local group = vim.api.nvim_create_augroup("ow.git.sidebar", { clear = false }) -local ns = vim.api.nvim_create_namespace("ow.git.sidebar") +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() +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 == "gitsidebar" then + if vim.bo[buf].filetype == "gitstatus" then return win, buf end end end ----@param s ow.Git.SidebarState +---@param s ow.Git.StatusView.State ---@return integer? -local function sidebar_win_for(s) - local win = s.sidebar_win +local function win_for(s) + local win = s.win if win and vim.api.nvim_win_is_valid(win) then return win end - win = find_sidebar() - s.sidebar_win = win + win = find_view() + s.win = win return win end ----@param entry ow.Git.SidebarEntry ----@return string? -local function entry_code(entry) - if entry.section == "Untracked" then - return "??" - elseif entry.section == "Unmerged" then - return entry.x .. entry.y - elseif entry.section == "Staged" then - return entry.x .. " " - elseif entry.section == "Unstaged" then - return " " .. entry.y - end -end - ----@param entry ow.Git.SidebarEntry ----@return string? line ----@return string? hl_group ----@return integer? hl_len +---@param entry ow.Git.Status.Entry +---@return string line +---@return string hl_group +---@return integer hl_len 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 = status.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 + return string.format(" %s %s", entry.char, label), entry.hl, #entry.char end ----@param stdout string ----@return ow.Git.BranchInfo, table -local function parse_porcelain(stdout) - local branch, groups = repo.parse_porcelain(stdout) - ---@cast groups table - groups.Unpushed = {} - groups.Unpulled = {} - return branch, groups -end - ----@param worktree string ----@param branch ow.Git.BranchInfo ----@param groups table -local function enrich_with_log(worktree, branch, groups) - 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 - local pending = {} - for _, f in ipairs(fetches) do - table.insert(pending, { - f = f, - sys = vim.system({ - "git", - "log", - "--max-count=200", - "--format=%h %s", - f.range, - }, { cwd = worktree, text = true }), - }) - end - for _, p in ipairs(pending) do - local result = p.sys:wait() - if result.code == 0 then - for line in (result.stdout or ""):gmatch("[^\r\n]+") do - local sha, subject = line:match("^(%S+)%s+(.+)$") - if sha then - table.insert(groups[p.f.section], { - section = p.f.section, - sha = sha, - subject = subject, - }) - end - end - else - util.error( - "git log %s failed: %s", - p.f.range, - vim.trim(result.stderr or "") - ) - end - end -end - ----@param worktree string ----@param prefetched_stdout string? ----@param callback fun(branch: ow.Git.BranchInfo, groups: table) -local function fetch_status(worktree, prefetched_stdout, callback) - if prefetched_stdout then - local branch, groups = parse_porcelain(prefetched_stdout) - enrich_with_log(worktree, branch, groups) - callback(branch, groups) - return - end - vim.system( - { - "git", - "-c", - "core.quotePath=false", - "status", - "--porcelain=v1", - "--branch", - }, - { cwd = worktree, text = true }, - vim.schedule_wrap(function(obj) - if obj.code ~= 0 then - util.error("git status failed: %s", vim.trim(obj.stderr or "")) - local branch = { ahead = 0, behind = 0 } - local groups = { - Untracked = {}, - Unstaged = {}, - Staged = {}, - Unmerged = {}, - Unpushed = {}, - Unpulled = {}, - } - callback(branch, groups) - return - end - local branch, groups = parse_porcelain(obj.stdout or "") - enrich_with_log(worktree, branch, groups) - callback(branch, groups) - end) - ) +---@param kind ow.Git.Status.EntryKind +---@return string +local function display_name(kind) + return (kind:gsub("^%l", string.upper)) end ---@param bufnr integer ----@param branch ow.Git.BranchInfo ----@param groups table -local function render(bufnr, branch, groups) +---@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 @@ -225,24 +86,23 @@ local function render(bufnr, branch, groups) 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 _, kind in ipairs(KINDS) do + local entries = status:by_kind(kind) + if #entries > 0 then + table.insert( + lines, + string.format("%s (%d)", display_name(kind), #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 + table.insert(lines, line) + meta[#lines] = entry + table.insert(marks, { + row = #lines - 1, + col = 2, + end_col = 2 + hl_len, + hl = hl, + }) end table.insert(lines, "") end @@ -261,89 +121,78 @@ local function render(bufnr, branch, groups) state[bufnr].lines = meta end ----@param branch ow.Git.BranchInfo ----@param groups table +---@param status ow.Git.Status ---@return string -local function fingerprint(branch, groups) +local function fingerprint(status) + local branch = status.branch local parts = { branch.head or "", branch.upstream or "", tostring(branch.ahead), tostring(branch.behind), } - for _, section in ipairs(SECTIONS) do - local entries = groups[section] - if entries then - for _, e in ipairs(entries) do - table.insert( - parts, - e.section - .. ":" - .. (e.path or e.sha or "") - .. ":" - .. (e.orig or "") - .. ":" - .. (e.x or "") - .. ":" - .. (e.y or "") - ) - end + for _, kind in ipairs(KINDS) do + for _, e in ipairs(status:by_kind(kind)) do + table.insert( + parts, + e.kind + .. ":" + .. e.path + .. ":" + .. (e.orig or "") + .. ":" + .. e.char + ) end end return table.concat(parts, "\0") end ---@param bufnr integer ----@param prefetched_stdout string? -local function refresh(bufnr, prefetched_stdout) +local function refresh(bufnr) local s = state[bufnr] if not s then return end - local saved_path, saved_sha - local sidebar_win = sidebar_win_for(s) - if sidebar_win then - local lnum = vim.api.nvim_win_get_cursor(sidebar_win)[1] + local saved_path + local status_win = win_for(s) + if status_win then + local lnum = vim.api.nvim_win_get_cursor(status_win)[1] local entry = s.lines[lnum] if entry then saved_path = entry.path - saved_sha = entry.sha end end - fetch_status(s.repo.worktree, prefetched_stdout, function(branch, groups) - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - s.last_shown_key = nil - local fp = fingerprint(branch, groups) - if fp == s.last_render_key then - return - end - s.last_render_key = fp - 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 - local win = sidebar_win_for(s) - if win then - pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 }) - end - break + if not vim.api.nvim_buf_is_valid(bufnr) then + 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 + render(bufnr, status) + if not saved_path then + return + end + for lnum, entry in pairs(s.lines) do + if entry.path == saved_path then + local win = win_for(s) + if win then + pcall(vim.api.nvim_win_set_cursor, win, { lnum, 0 }) end + break end - end) + end end ---@param bufnr integer ----@return ow.Git.SidebarState? ----@return ow.Git.SidebarEntry? +---@return ow.Git.StatusView.State? +---@return ow.Git.Status.Entry? local function current_entry(bufnr) local s = state[bufnr] if not s then @@ -353,17 +202,9 @@ local function current_entry(bufnr) return s, s.lines[lnum] 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 r ow.Git.Repo ---@param path string ----@return ow.Git.DiffSide +---@return ow.Git.Diff.Side local function head_pane(r, path) local rev = Revision.new({ base = "HEAD", path = path }) return { @@ -374,16 +215,16 @@ end ---@param r ow.Git.Repo ---@param path string ----@return ow.Git.DiffSide +---@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.SidebarState ----@param entry ow.Git.StatusEntry ----@return ow.Git.DiffSide +---@param s ow.Git.StatusView.State +---@param entry ow.Git.Status.Entry +---@return ow.Git.Diff.Side local function index_pane(s, entry) local rev = Revision.new({ stage = 0, path = entry.path }) return { @@ -392,39 +233,39 @@ local function index_pane(s, entry) } end ----@param s ow.Git.SidebarState ----@param entry ow.Git.StatusEntry ----@return ow.Git.DiffSide? +---@param s ow.Git.StatusView.State +---@param entry ow.Git.Status.Entry +---@return ow.Git.Diff.Side? local function older_pane(s, entry) - if entry.section == "Staged" then - if entry.x == "A" then + if entry.kind == "staged" then + if entry.char == "A" then return nil end return head_pane(s.repo, entry.orig or entry.path) end - if entry.section == "Unstaged" then + if entry.kind == "unstaged" then return index_pane(s, entry) end return nil end ----@param s ow.Git.SidebarState ----@param entry ow.Git.StatusEntry ----@return ow.Git.DiffSide? +---@param s ow.Git.StatusView.State +---@param entry ow.Git.Status.Entry +---@return ow.Git.Diff.Side? local function newer_pane(s, entry) - if entry.section == "Staged" then - if entry.x == "D" then + if entry.kind == "staged" then + if entry.char == "D" then return nil end return index_pane(s, entry) end - if entry.section == "Unstaged" then - if entry.y == "D" then + if entry.kind == "unstaged" then + if entry.char == "D" then return nil end return worktree_pane(s.repo, entry.path) end - if entry.section == "Untracked" then + if entry.kind == "untracked" then return worktree_pane(s.repo, entry.path) end return nil @@ -439,14 +280,14 @@ local function reset_diff_win(win) end) end ----@param s ow.Git.SidebarState +---@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.sidebar_win then + if win == s.win then return nil end if @@ -458,11 +299,11 @@ local function invocation_win_for(s) return win end ----@param s ow.Git.SidebarState ----@param sidebar_win integer +---@param s ow.Git.StatusView.State +---@param status_win integer ---@return integer? left ---@return integer? right -local function adopt_diff_wins(s, sidebar_win) +local function adopt_diff_wins(s, status_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 @@ -475,7 +316,7 @@ local function adopt_diff_wins(s, sidebar_win) return left, right end for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do - if win ~= sidebar_win then + if win ~= status_win then local role = vim.w[win].git_diff_role if role == "left" and not left then left = win @@ -487,10 +328,10 @@ local function adopt_diff_wins(s, sidebar_win) return left, right end ----@param entry ow.Git.StatusEntry +---@param entry ow.Git.Status.Entry ---@return string local function entry_key(entry) - return entry.section .. "|" .. entry.path .. "|" .. (entry.orig or "") + return entry.kind .. "|" .. entry.path .. "|" .. (entry.orig or "") end ---@param target_win integer @@ -506,11 +347,11 @@ local function vsplit_at(target_win, dir) return win end ----@param s ow.Git.SidebarState ----@param sidebar_win integer +---@param s ow.Git.StatusView.State +---@param status_win integer ---@param right_win integer? ---@return integer -local function ensure_right_win(s, sidebar_win, right_win) +local function ensure_right_win(s, status_win, right_win) if right_win then return right_win end @@ -518,46 +359,38 @@ local function ensure_right_win(s, sidebar_win, right_win) if target then right_win = target else - right_win = vsplit_at(sidebar_win, "right") - vim.api.nvim_win_set_width(sidebar_win, SIDEBAR_WIDTH) + 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.SidebarState ----@param entry ow.Git.SidebarEntry +---@param s ow.Git.StatusView.State +---@param entry ow.Git.Status.Entry ---@param focus_left boolean local function view_entry(s, entry, focus_left) - if not entry.path then - return - end - ---@cast entry ow.Git.StatusEntry - local sidebar_win = sidebar_win_for(s) - if not sidebar_win then + local status_win = win_for(s) + if not status_win then return end local left = older_pane(s, entry) local right = newer_pane(s, entry) if not left and not right then - util.warning( - "no content for %s entry: %s", - string.lower(entry.section), - entry.path - ) + util.warning("no content for %s entry: %s", entry.kind, entry.path) return end local key = entry_key(entry) - local left_win, right_win = adopt_diff_wins(s, sidebar_win) + 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 sidebar_win + local target = focus_left and (left_win or right_win) or status_win vim.api.nvim_set_current_win(target) return end @@ -569,22 +402,22 @@ local function view_entry(s, entry, focus_left) left_win = nil s.diff_left_win = nil end - right_win = ensure_right_win(s, sidebar_win, right_win) + 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.DiffSide + ---@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 sidebar_win) + vim.api.nvim_set_current_win(focus_left and right_win or status_win) return end - ---@cast left ow.Git.DiffSide - ---@cast right ow.Git.DiffSide + ---@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") @@ -593,7 +426,7 @@ local function view_entry(s, entry, focus_left) 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, sidebar_win, nil) + 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) @@ -611,7 +444,7 @@ local function view_entry(s, entry, focus_left) 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 sidebar_win) + vim.api.nvim_set_current_win(focus_left and left_win or status_win) end ---@param focus_left boolean @@ -625,11 +458,10 @@ 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 + if not s or not entry then return end - ---@cast entry ow.Git.StatusEntry - if entry.section == "Staged" then + if entry.kind == "staged" then return end vim.system( @@ -645,11 +477,10 @@ 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 + if not s or not entry then return end - ---@cast entry ow.Git.StatusEntry - if entry.section ~= "Staged" then + if entry.kind ~= "staged" then return end local cmd = { "git", "restore", "--staged", "--" } @@ -673,17 +504,16 @@ 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 + if not s or not entry then return end - ---@cast entry ow.Git.StatusEntry - if entry.section == "Staged" then + if entry.kind == "staged" then util.warning("file has staged changes, unstage first with 'u'") return end local prompt, action - if entry.section == "Untracked" then + if entry.kind == "untracked" then local is_dir = entry.path:sub(-1) == "/" prompt = string.format( "Delete untracked %s %s?", @@ -698,7 +528,7 @@ local function action_discard() end refresh(vim.api.nvim_get_current_buf()) end - elseif entry.section == "Unstaged" then + elseif entry.kind == "unstaged" then prompt = string.format("Discard changes to %s?", entry.path) action = function() vim.system( @@ -725,7 +555,7 @@ end local function action_help() print(table.concat({ - "git status sidebar", + "git status window", " preview diff (keep focus)", " open diff (focus left pane)", " s stage file", @@ -737,7 +567,7 @@ end ---@param r ow.Git.Repo local function open(r) - local existing = find_sidebar() + local existing = find_view() if existing then vim.api.nvim_set_current_win(existing) return @@ -745,7 +575,7 @@ local function open(r) local previous_win = vim.api.nvim_get_current_win() local bufnr, win = util.new_scratch({ split = "left" }) - vim.bo[bufnr].filetype = "gitsidebar" + vim.bo[bufnr].filetype = "gitstatus" vim.wo[win].number = false vim.wo[win].relativenumber = false @@ -753,12 +583,12 @@ local function open(r) vim.wo[win].signcolumn = "no" vim.wo[win].cursorline = true vim.wo[win].winfixwidth = true - vim.api.nvim_win_set_width(win, SIDEBAR_WIDTH) + vim.api.nvim_win_set_width(win, WINDOW_WIDTH) state[bufnr] = { repo = r, lines = {}, - sidebar_win = win, + win = win, invocation_win = previous_win, } @@ -781,8 +611,8 @@ local function open(r) k("X", action_discard, "Discard worktree changes") k("g?", action_help, "Help") - state[bufnr].unsubscribe = r:on_refresh(function(_, porcelain_stdout) - refresh(bufnr, porcelain_stdout) + state[bufnr].unsubscribe = r:on("refresh", function() + refresh(bufnr) end) vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, { buffer = bufnr, @@ -804,12 +634,12 @@ local function open(r) end function M.toggle() - local sidebar_win = find_sidebar() - if sidebar_win then - vim.api.nvim_win_close(sidebar_win, false) + local status_win = find_view() + if status_win then + vim.api.nvim_win_close(status_win, false) return end - local r = repo.find() + local r = repo.resolve() if not r then util.warning("not in a git repository") return diff --git a/lua/git/statusline.lua b/lua/git/statusline.lua new file mode 100644 index 0000000..24186b7 --- /dev/null +++ b/lua/git/statusline.lua @@ -0,0 +1,31 @@ +local repo = require("git.repo") + +local M = {} + +---@return string +function M.render() + local r = repo.find() + if not r then + return "" + end + local name = vim.api.nvim_buf_get_name(0) + if name == "" then + return "" + end + local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name)) + local list = rel and r.status.entries[rel] + if not list then + return "" + end + local parts = {} + for _, e in ipairs(list) do + table.insert(parts, string.format("%%#%s#%s%%*", e.hl, e.char)) + end + return table.concat(parts, " ") +end + +repo.on("refresh", function() + vim.cmd.redrawstatus({ bang = true }) +end) + +return M diff --git a/lua/git/util.lua b/lua/git/util.lua index c59a6fa..129857e 100644 --- a/lua/git/util.lua +++ b/lua/git/util.lua @@ -1,11 +1,11 @@ local M = {} ----@class ow.Git.ScratchOpts +---@class ow.Git.Util.ScratchOpts ---@field name string? ---@field bufhidden ("hide"|"wipe")? ---@param buf integer ----@param opts ow.Git.ScratchOpts +---@param opts ow.Git.Util.ScratchOpts local function setup_scratch(buf, opts) vim.bo[buf].buftype = "nofile" vim.bo[buf].bufhidden = opts.bufhidden or "wipe" @@ -43,10 +43,10 @@ function M.place_buf(buf, split) return win end ----@class ow.Git.NewScratchOpts : ow.Git.ScratchOpts +---@class ow.Git.Util.NewScratchOpts : ow.Git.Util.ScratchOpts ---@field split (false|"above"|"below"|"left"|"right")? ----@param opts ow.Git.NewScratchOpts? +---@param opts ow.Git.Util.NewScratchOpts? ---@return integer buf ---@return integer win function M.new_scratch(opts) @@ -170,14 +170,14 @@ function M.debounce(fn, delay) } end ----@class ow.Git.ExecOpts +---@class ow.Git.Util.ExecOpts ---@field cwd string? ---@field stdin string? ---@field silent boolean? ---@field on_done fun(stdout: string?)? ---@param cmd string[] ----@param opts ow.Git.ExecOpts? +---@param opts ow.Git.Util.ExecOpts? ---@return string? function M.exec(cmd, opts) opts = opts or {} @@ -209,4 +209,40 @@ function M.exec(cmd, opts) return handle(vim.system(cmd, sys_opts):wait()) end +---@class ow.Git.Util.Emitter +---@field private _listeners table +local Emitter = {} +Emitter.__index = Emitter + +---@return ow.Git.Util.Emitter +function Emitter.new() + return setmetatable({ _listeners = {} }, Emitter) +end + +---@param event T +---@param fn fun(...) +---@return fun() unsubscribe +function Emitter:on(event, fn) + local list = self._listeners[event] or {} + self._listeners[event] = list + table.insert(list, fn) + return function() + for i, f in ipairs(list) do + if f == fn then + table.remove(list, i) + return + end + end + end +end + +---@param event T +function Emitter:emit(event, ...) + for _, fn in ipairs(self._listeners[event] or {}) do + fn(...) + end +end + +M.Emitter = Emitter + return M diff --git a/plugins/nvim-tree.lua b/plugins/nvim-tree.lua index ac6d14a..1f597f2 100644 --- a/plugins/nvim-tree.lua +++ b/plugins/nvim-tree.lua @@ -41,29 +41,88 @@ end) ---@class nvim_tree.api.decorator.UserDecorator local Decorator = require("nvim-tree.api").Decorator ----@class GitIgnoreDecorator: nvim_tree.api.decorator.UserDecorator -local GitIgnoreDecorator = Decorator:extend() +local repo = require("git.repo") -function GitIgnoreDecorator:new() +repo.on("refresh", function() + require("nvim-tree.api").tree.reload() +end) + +do + local events = require("nvim-tree.api").events + local function on_change(data) + if not data then + return + end + local seen = {} + local function fire(path) + if not path then + return + end + local r = repo.find(path) + if r and not seen[r] then + seen[r] = true + r:refresh() + end + end + fire(data.fname) + fire(data.folder_name) + fire(data.old_name) + fire(data.new_name) + end + events.subscribe(events.Event.FileCreated, on_change) + events.subscribe(events.Event.FileRemoved, on_change) + events.subscribe(events.Event.FolderCreated, on_change) + events.subscribe(events.Event.FolderRemoved, on_change) + events.subscribe(events.Event.NodeRenamed, on_change) +end + +---@class ow.GitDecorator: nvim_tree.api.Decorator +local GitDecorator = Decorator:extend() + +function GitDecorator:new() self.enabled = true + self.icon_placement = "after" self.highlight_range = "name" - self.icon_placement = "none" - self.file_hl = "NvimTreeGitFileIgnoredHL" - self.folder_hl = "NvimTreeGitFolderIgnoredHL" end ---@param node Node ----@return string? highlight_group -function GitIgnoreDecorator:highlight_group(node) - local status = node.git_status - if not status then +---@return ow.Git.Status.Entry[]? +local function entries_for(node) + local r = repo.find(node.absolute_path) + if not r then return end + local rel = vim.fs.relpath(r.worktree, node.absolute_path) + return rel and r.status.entries[rel] +end - if status.file == "!!" then - return self.file_hl - elseif status.dir and status.dir.direct == "!!" then - return self.folder_hl +---@param node Node +---@return { str: string, hl: string[] }[]? +function GitDecorator.icons(_, node) + local list = entries_for(node) + if not list then + return + end + local out = {} + for _, entry in ipairs(list) do + if entry.kind ~= "ignored" then + table.insert(out, { str = entry.char, hl = { entry.hl } }) + end + end + return out +end + +---@param node Node +---@return string? +function GitDecorator.highlight_group(_, node) + local list = entries_for(node) + if not list then + return + end + for _, entry in ipairs(list) do + if entry.kind == "ignored" then + return entry.hl + end end end @@ -120,8 +179,7 @@ require("nvim-tree").setup({ end, special_files = {}, decorators = { - "Git", - GitIgnoreDecorator, + GitDecorator, "Open", "Modified", "Bookmark", @@ -140,7 +198,6 @@ require("nvim-tree").setup({ }, }, icons = { - git_placement = "after", diagnostics_placement = "signcolumn", bookmarks_placement = "after", symlink_arrow = " -> ", @@ -157,15 +214,6 @@ require("nvim-tree").setup({ }, glyphs = { modified = "*", - git = { - unstaged = "M", - staged = "M", - unmerged = "!", - renamed = "R", - untracked = "?", - deleted = "D", - ignored = " ", - }, }, }, }, @@ -186,7 +234,6 @@ require("nvim-tree").setup({ enable = true, }, filters = { - git_ignored = false, custom = { "^\\.git$" }, }, live_filter = { @@ -219,7 +266,7 @@ require("nvim-tree").setup({ }, sync_root_with_cwd = true, git = { - show_on_open_dirs = false, + enable = false, }, }) diff --git a/plugins/onedark.lua b/plugins/onedark.lua index 9b3d777..84c186f 100644 --- a/plugins/onedark.lua +++ b/plugins/onedark.lua @@ -53,9 +53,7 @@ local highlights = { DiffAdd = { bg = "#1a2f22" }, DiffChange = { bg = "#15304a" }, DiffDelete = { bg = "#311c1e" }, - -- GitDeleted = { fg = c.red }, - -- GitUnstaged = { fg = c.yellow }, - -- GitUntracked = { fg = c.green }, + Changed = { fg = c.yellow }, } for kind, color in pairs(completion_kind_colors) do highlights["LspKind" .. kind] = { fg = color } diff --git a/syntax/gitsidebar.vim b/syntax/gitsidebar.vim deleted file mode 100644 index 33879aa..0000000 --- a/syntax/gitsidebar.vim +++ /dev/null @@ -1,44 +0,0 @@ -if exists("b:current_syntax") - finish -endif - -syntax match gitSidebarLabel /\v^(Head|Push)\ze:/ -syntax match gitSidebarBranch /\v(^(Head|Push):\s+)@<=\S+/ -syntax match gitSidebarAhead /\v\+\d+/ -syntax match gitSidebarBehind /\v-\d+/ - -syntax region gitSidebarUntrackedHeader start=/\v^Untracked>/ end=/\v^$/ -syntax region gitSidebarUnstagedHeader start=/\v^Unstaged>/ end=/\v^$/ -syntax region gitSidebarStagedHeader start=/\v^Staged>/ end=/\v^$/ -syntax region gitSidebarUnmergedHeader start=/\v^Unmerged>/ end=/\v^$/ -syntax region gitSidebarUnpushedHeader start=/\v^Unpushed>/ end=/\v^$/ -syntax region gitSidebarUnpulledHeader start=/\v^Unpulled>/ end=/\v^$/ - -syntax match gitSidebarUntrackedLabel /\v^Untracked/ contained containedin=gitSidebarUntrackedHeader -syntax match gitSidebarUnstagedLabel /\v^Unstaged/ contained containedin=gitSidebarUnstagedHeader -syntax match gitSidebarStagedLabel /\v^Staged/ contained containedin=gitSidebarStagedHeader -syntax match gitSidebarUnmergedLabel /\v^Unmerged/ contained containedin=gitSidebarUnmergedHeader -syntax match gitSidebarUnpushedLabel /\v^Unpushed/ contained containedin=gitSidebarUnpushedHeader -syntax match gitSidebarUnpulledLabel /\v^Unpulled/ contained containedin=gitSidebarUnpulledHeader - -syntax match gitSidebarHeaderCount /\v\(\zs\d+\ze\)/ contained containedin=gitSidebarUntrackedHeader, - \ gitSidebarUnstagedHeader, - \ gitSidebarStagedHeader, - \ gitSidebarUnmergedHeader, - \ gitSidebarUnpushedHeader, - \ gitSidebarUnpulledHeader - -highlight default link gitSidebarLabel Label -highlight default link gitSidebarBranch None -highlight default link gitSidebarAhead GitUnpushed -highlight default link gitSidebarBehind GitUnpulled -highlight default link gitSidebarHeaderCount Number - -highlight default link gitSidebarUntrackedLabel gitSidebarLabel -highlight default link gitSidebarUnstagedLabel gitSidebarLabel -highlight default link gitSidebarStagedLabel gitSidebarLabel -highlight default link gitSidebarUnmergedLabel gitSidebarLabel -highlight default link gitSidebarUnpushedLabel gitSidebarLabel -highlight default link gitSidebarUnpulledLabel gitSidebarLabel - -let b:current_syntax = "gitSidebar" diff --git a/syntax/gitstatus.vim b/syntax/gitstatus.vim new file mode 100644 index 0000000..ee795ac --- /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"