refactor(git): remove blame gutter

This commit is contained in:
2026-05-26 15:28:22 +02:00
parent 27f77e4fb7
commit ebfa15c276
4 changed files with 3 additions and 473 deletions
+3 -263
View File
@@ -8,15 +8,6 @@ local NS_INLINE = vim.api.nvim_create_namespace("ow.git.blame.inline")
local NS_POPUP = vim.api.nvim_create_namespace("ow.git.blame.popup")
local ZERO_SHA = string.rep("0", 40)
local BLAME_EXPR = "%{%v:lua.require('git.blame').statuscolumn()%}"
-- Neovim collapses the gutter once the statuscolumn passes 47 cells.
local STATUSCOLUMN_MAX = 47
local GAP = " "
local SHA_WIDTH = 8
local AUTHOR_MAX = 16
local DATE_WIDTH = 10
local PREFERRED_WIDTH = SHA_WIDTH + AUTHOR_MAX + DATE_WIDTH + 3 * #GAP
---@class ow.Git.Blame.Commit
---@field sha string
@@ -41,14 +32,10 @@ local PREFERRED_WIDTH = SHA_WIDTH + AUTHOR_MAX + DATE_WIDTH + 3 * #GAP
---@field revision string? nil = working tree, else the blamed revision
---@field commits table<string, ow.Git.Blame.Commit>
---@field line_sha table<integer, string>
---@field blame_text table<integer, string>?
---@field blame_width integer?
---@field blame_blank string?
---@field tick integer?
---@field epoch integer
---@field pending fun()[]
---@field inline boolean
---@field gutter boolean
---@field autocmds integer[]
---@type table<integer, ow.Git.Blame.BufState>
@@ -116,17 +103,6 @@ local function format_author_time(ts, tz)
return os.date("!%Y-%m-%d %T ", ts + offset) .. tz
end
---@param s string
---@param width integer
---@return string
local function pad(s, width)
local w = vim.api.nvim_strwidth(s)
if w > width then
return vim.fn.strcharpart(s, 0, width)
end
return s .. string.rep(" ", width - w)
end
---@param stdout string
---@return ow.Git.Blame.Result
local function parse_porcelain(stdout)
@@ -284,14 +260,10 @@ local function ensure_state(buf)
revision = src.revision,
commits = {},
line_sha = {},
blame_text = nil,
blame_width = nil,
blame_blank = nil,
tick = nil,
epoch = 0,
pending = {},
inline = false,
gutter = false,
autocmds = {},
}
states[buf] = state
@@ -340,7 +312,6 @@ local function run_blame(state, buf, done)
or synth_uncommitted(vim.api.nvim_buf_line_count(buf))
state.commits = data.commits
state.line_sha = data.line_sha
state.blame_text = nil
state.tick = tick
local pending = state.pending
state.pending = {}
@@ -390,196 +361,14 @@ local function render_inline(buf)
})
end
---The maximum width the native fold / sign / number columns can occupy.
---Computed from the window options, not evaluated: evaluating a
---statuscolumn reports the window's current gutter width, which the
---gutter itself has already inflated.
---@param win integer
---@return integer
local function native_width(win)
local wo = vim.wo[win]
local width = 0
if wo.number or wo.relativenumber then
local buf = vim.api.nvim_win_get_buf(win)
local digits = #tostring(vim.api.nvim_buf_line_count(buf)) + 1
width = math.max(wo.numberwidth, digits)
end
local sc = wo.signcolumn
if sc:find("^yes") or sc:find("^auto") then
width = width + 2 * math.floor(tonumber(sc:match("(%d)$")) or 1)
end
local fc = tonumber(wo.foldcolumn:match("(%d)$"))
if fc then
width = width + math.floor(fc)
elseif wo.foldcolumn:find("^auto") then
width = width + 1
end
return width
end
---Split a `budget` of display cells across the blame fields. The author
---absorbs the squeeze first, then the date, then the sha.
---@param budget integer
---@return integer sha_w
---@return integer author_w
---@return integer date_w
local function layout(budget)
local body = budget - 3 * #GAP
if body <= 0 then
return 0, 0, 0
end
local sha_w = math.min(body, SHA_WIDTH)
local rest = body - sha_w
local date_w = math.min(rest, DATE_WIDTH)
return sha_w, rest - date_w, date_w
end
---Precompute the per-line blame segment so the statuscolumn expression
---stays a table lookup. Blame shares Neovim's 47-cell statuscolumn cap
---with the native columns, so it is budgeted into what they leave free.
---@param state ow.Git.Blame.BufState
---@param win integer
local function build_blame_text(state, win)
local total = math.max(
0,
math.min(STATUSCOLUMN_MAX - native_width(win), PREFERRED_WIDTH)
)
local sha_w, author_w, date_w = layout(total)
local blank = string.rep(" ", total)
---@type table<integer, string>
local text = {}
for lnum, sha in pairs(state.line_sha) do
local commit = state.commits[sha]
if commit then
if sha_w == 0 then
text[lnum] = blank
else
local author, date
if util.is_zero_sha(sha) then
author, date = "Uncommitted", ""
else
author = commit.author
date = os.date("%Y-%m-%d", commit.author_time)
end
text[lnum] = "%#GitBlameSha#"
.. sha:sub(1, sha_w)
.. "%#GitBlameAuthor#"
.. GAP
.. (pad(author, author_w):gsub("%%", "%%%%"))
.. "%#GitBlameDate#"
.. GAP
.. pad(date, date_w)
.. GAP
end
end
end
state.blame_text = text
state.blame_width = total
state.blame_blank = blank
end
---Render the blame segment for one screen line. Wired into the window's
---`'statuscolumn'` while the gutter is on, so the cursor never enters
---it - it lives outside the text area, unlike inline virtual text.
---@param win integer?
---@param lnum integer
---@param virtnum integer
---@return string
local function gutter(win, lnum, virtnum)
if not win or win == 0 or not vim.api.nvim_win_is_valid(win) then
return ""
end
local state = states[vim.api.nvim_win_get_buf(win)]
if not state or not state.gutter or not state.blame_text then
return ""
end
return (virtnum == 0 and state.blame_text[lnum]) or state.blame_blank or ""
end
function M.statuscolumn()
local ok, result =
pcall(gutter, vim.api.nvim_get_current_win(), vim.v.lnum, vim.v.virtnum)
return ok and result or ""
end
---@type table<integer, string>
local saved_statuscolumn = {}
---Set a window's `'statuscolumn'` with `scope = "local"`, so the global
---value stays clean and splits and new windows do not inherit the gutter.
---@param win integer
---@param value string
local function set_statuscolumn(win, value)
vim.api.nvim_set_option_value(
"statuscolumn",
value,
{ win = win, scope = "local" }
)
end
---Reconcile every window's `'statuscolumn'` with the gutter state.
---Gutter windows get the blame statuscolumn, and a window that has it
---but should not (a split inherits window options) is restored.
local function refresh_gutter_columns()
for win in pairs(saved_statuscolumn) do
if not vim.api.nvim_win_is_valid(win) then
saved_statuscolumn[win] = nil
end
end
for _, win in ipairs(vim.api.nvim_list_wins()) do
local state = states[vim.api.nvim_win_get_buf(win)]
local on = state ~= nil and state.gutter
local has_blame = vim.startswith(vim.wo[win].statuscolumn, BLAME_EXPR)
if on and not has_blame then
saved_statuscolumn[win] = saved_statuscolumn[win]
or vim.wo[win].statuscolumn
set_statuscolumn(
win,
BLAME_EXPR .. "%C%s%l" .. (native_width(win) > 0 and " " or "")
)
elseif not on and has_blame then
set_statuscolumn(
win,
saved_statuscolumn[win] or vim.go.statuscolumn
)
saved_statuscolumn[win] = nil
end
end
end
---@param buf integer
local function render(buf)
render_inline(buf)
local state = states[buf]
if not state or not state.gutter then
return
end
-- Rebuild against the current native column widths, then re-set
-- `'statuscolumn'` so the redraw picks up the new text instead of the
-- cached gutter.
local built = false
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_get_buf(win) == buf then
if not built then
build_blame_text(state, win)
built = true
end
set_statuscolumn(
win,
BLAME_EXPR .. "%C%s%l" .. (native_width(win) > 0 and " " or "")
)
end
end
end
---@param buf integer
local function reblame(buf)
local state = states[buf]
if not state or (not state.inline and not state.gutter) then
if not state or not state.inline then
return
end
run_blame(state, buf, function()
render(buf)
render_inline(buf)
end)
end
@@ -648,28 +437,6 @@ function M.toggle_inline(buf)
if vim.api.nvim_buf_is_valid(buf) then
vim.api.nvim_buf_clear_namespace(buf, NS_INLINE, 0, -1)
end
if not state.gutter then
detach_autocmds(buf, state)
end
end
end
---@param buf integer?
function M.toggle_gutter(buf)
buf = resolve_buf(buf)
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
state.gutter = not state.gutter
refresh_gutter_columns()
if state.gutter then
attach_autocmds(buf, state)
run_blame(state, buf, function()
render(buf)
end)
elseif not state.inline then
detach_autocmds(buf, state)
end
end
@@ -920,35 +687,8 @@ function M.detach(buf)
detach_autocmds(buf, state)
state.epoch = state.epoch + 1
states[buf] = nil
refresh_gutter_columns()
end
local augroup = vim.api.nvim_create_augroup("ow.git.blame", { clear = true })
vim.api.nvim_create_autocmd("BufWinEnter", {
group = augroup,
callback = refresh_gutter_columns,
})
-- The blame budget depends on the gutter option widths, so re-render an
-- gutter buffer when one of them changes.
vim.api.nvim_create_autocmd("OptionSet", {
group = augroup,
pattern = {
"number",
"relativenumber",
"numberwidth",
"signcolumn",
"foldcolumn",
},
callback = function()
local buf = vim.api.nvim_get_current_buf()
local state = states[buf]
if state and state.gutter then
render(buf)
end
end,
})
-- The blame cache is keyed by `changedtick`, which a commit / checkout /
-- rebase does not bump. Drop the cache for affected worktree buffers on
-- a repo change so the next blame re-fetches; re-blame eagerly if a mode
@@ -961,7 +701,7 @@ repo.on("change", function(r, change)
and (change.paths[state.rel] or change.branch_changed)
then
state.tick = nil
if state.inline or state.gutter then
if state.inline then
schedule(buf)
end
end