refactor(git): rework blame highlights and rename overlay to gutter

This commit is contained in:
2026-05-26 14:52:59 +02:00
parent a0a8d723d6
commit c560f62fb2
5 changed files with 157 additions and 109 deletions
+2 -2
View File
@@ -235,11 +235,11 @@ vim.keymap.set("n", "<leader>gc", "<Plug>(git-commit)")
vim.keymap.set("n", "<leader>ga", "<Plug>(git-commit-amend)")
vim.keymap.set("n", "<leader>gl", "<Plug>(git-log)")
vim.keymap.set("n", "<leader>gb", "<Plug>(git-blame-popup)")
vim.keymap.set("n", "<leader>gB", "<Plug>(git-blame)")
vim.keymap.set("n", "<leader>gB", "<Plug>(git-blame-gutter)")
vim.keymap.set("n", "<leader>gv", "<Plug>(git-hunk-select)")
vim.keymap.set("n", "<leader>gs", "<Plug>(git-hunk-stage-toggle)")
vim.keymap.set("n", "<leader>gr", "<Plug>(git-hunk-reset)")
vim.keymap.set("n", "<C-w>g", "<Plug>(git-hunk-preview)")
vim.keymap.set("n", "<leader>go", "<Plug>(git-diff-overlay)")
vim.keymap.set("n", "<leader>go", "<Plug>(git-hunk-overlay-toggle)")
vim.keymap.set({ "n", "x" }, "]g", "<Plug>(git-hunk-next)")
vim.keymap.set({ "n", "x" }, "[g", "<Plug>(git-hunk-prev)")
+70 -52
View File
@@ -41,14 +41,14 @@ 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>? cached overlay gutter text
---@field blame_width integer? display width of each cached segment
---@field blame_blank string? a blank segment of that width
---@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 overlay boolean
---@field gutter boolean
---@field autocmds integer[]
---@type table<integer, ow.Git.Blame.BufState>
@@ -66,13 +66,6 @@ function M.state(buf)
return states[buf]
end
---@param n integer
---@param unit string
---@return string
local function plural(n, unit)
return string.format("%d %s%s ago", n, unit, n == 1 and "" or "s")
end
---@param unix_ts integer
---@return string
local function relative_time(unix_ts)
@@ -80,34 +73,47 @@ local function relative_time(unix_ts)
if diff < 0 then
diff = 0
end
local function fmt(n, unit)
return string.format("%d %s%s ago", n, unit, n == 1 and "" or "s")
end
if diff < 45 then
return "just now"
elseif diff < 90 then
return "a minute ago"
elseif diff < 45 * 60 then
return plural(math.floor(diff / 60 + 0.5), "minute")
return fmt(math.floor(diff / 60 + 0.5), "minute")
elseif diff < 90 * 60 then
return "an hour ago"
elseif diff < 22 * 3600 then
return plural(math.floor(diff / 3600 + 0.5), "hour")
return fmt(math.floor(diff / 3600 + 0.5), "hour")
elseif diff < 36 * 3600 then
return "a day ago"
elseif diff < 7 * 86400 then
return plural(math.floor(diff / 86400 + 0.5), "day")
return fmt(math.floor(diff / 86400 + 0.5), "day")
elseif diff < 30 * 86400 then
return plural(math.floor(diff / (7 * 86400) + 0.5), "week")
return fmt(math.floor(diff / (7 * 86400) + 0.5), "week")
elseif diff < 365 * 86400 then
return plural(math.floor(diff / (30 * 86400) + 0.5), "month")
return fmt(math.floor(diff / (30 * 86400) + 0.5), "month")
end
return plural(math.floor(diff / (365 * 86400) + 0.5), "year")
return fmt(math.floor(diff / (365 * 86400) + 0.5), "year")
end
M.relative_time = relative_time
---@param ts integer
---@param tz string
---@return string
local function format_date(ts)
return os.date("%Y-%m-%d", ts) --[[@as string]]
local function format_author_time(ts, tz)
local sign, hh, mm = tz:match("^([+-])(%d%d)(%d%d)$")
---@type number
local offset = 0
if sign then
local h = tonumber(hh) or 0
local m = tonumber(mm) or 0
offset = (h * 3600 + m * 60) * (sign == "-" and -1 or 1)
end
return os.date("!%Y-%m-%d %T ", ts + offset) .. tz
end
---@param s string
@@ -285,7 +291,7 @@ local function ensure_state(buf)
epoch = 0,
pending = {},
inline = false,
overlay = false,
gutter = false,
autocmds = {},
}
states[buf] = state
@@ -378,7 +384,7 @@ local function render_inline(buf)
)
end
pcall(vim.api.nvim_buf_set_extmark, buf, NS_INLINE, lnum - 1, 0, {
virt_text = { { text, "GitBlame" } },
virt_text = { { text, "GitBlameInline" } },
virt_text_pos = "eol",
hl_mode = "combine",
})
@@ -406,7 +412,7 @@ 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
---overlay itself has already inflated.
---gutter itself has already inflated.
---@param win integer
---@return integer
local function native_width(win)
@@ -472,13 +478,14 @@ local function build_blame_text(state, win)
author, date = "Uncommitted", ""
else
author = commit.author
date = format_date(commit.author_time)
date = os.date("%Y-%m-%d", commit.author_time)
end
text[lnum] = "%#GitBlameSha#"
.. sha:sub(1, sha_w)
.. "%#GitBlame#"
.. "%#GitBlameAuthor#"
.. GAP
.. (pad(author, author_w):gsub("%%", "%%%%"))
.. "%#GitBlameDate#"
.. GAP
.. pad(date, date_w)
.. GAP
@@ -491,7 +498,7 @@ local function build_blame_text(state, win)
end
---Render the blame segment for one screen line. Wired into the window's
---`'statuscolumn'` while the overlay is on, so the cursor never enters
---`'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
@@ -502,7 +509,7 @@ local function gutter(win, lnum, virtnum)
return ""
end
local state = states[vim.api.nvim_win_get_buf(win)]
if not state or not state.overlay or not state.blame_text then
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 ""
@@ -533,10 +540,10 @@ local function set_statuscolumn(win, value)
)
end
---Reconcile every window's `'statuscolumn'` with the overlay state.
---Overlay windows get the blame statuscolumn, and a window that has it
---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_overlay_columns()
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
@@ -544,7 +551,7 @@ local function refresh_overlay_columns()
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.overlay
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]
@@ -564,7 +571,7 @@ end
local function render(buf)
render_inline(buf)
local state = states[buf]
if not state or not state.overlay then
if not state or not state.gutter then
return
end
-- Rebuild against the current native column widths, then re-set
@@ -585,7 +592,7 @@ end
---@param buf integer
local function reblame(buf)
local state = states[buf]
if not state or (not state.inline and not state.overlay) then
if not state or (not state.inline and not state.gutter) then
return
end
run_blame(state, buf, function()
@@ -658,23 +665,23 @@ 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.overlay then
if not state.gutter then
detach_autocmds(buf, state)
end
end
end
---@param buf integer?
function M.toggle_overlay(buf)
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.overlay = not state.overlay
refresh_overlay_columns()
if state.overlay then
state.gutter = not state.gutter
refresh_gutter_columns()
if state.gutter then
attach_autocmds(buf, state)
run_blame(state, buf, function()
render(buf)
@@ -714,7 +721,8 @@ end
---@param head string[]
---@param body string[]?
---@param sha_len integer?
local function apply_popup(pbuf, win, head, body, sha_len)
---@param date_col integer?
local function apply_popup(pbuf, win, head, body, sha_len, date_col)
local lines = {}
vim.list_extend(lines, head)
if body then
@@ -729,9 +737,17 @@ local function apply_popup(pbuf, win, head, body, sha_len)
end_col = sha_len,
hl_group = "GitBlameSha",
})
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 1, 0, {
end_col = #(head[2] or ""),
hl_group = "GitBlame",
end
if sha_len and date_col then
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, sha_len + 2, {
end_col = date_col - 2,
hl_group = "GitBlameAuthor",
})
end
if date_col then
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, date_col, {
end_col = #(head[1] or ""),
hl_group = "GitBlameDate",
})
end
local width, height = size_for(lines)
@@ -780,14 +796,16 @@ local function open_popup(r, commits, line_sha, lnum, watch_buf)
end
local head ---@type string[]
local sha_len ---@type integer?
local date_col ---@type integer?
if util.is_zero_sha(sha) then
head = { "Not Committed Yet" }
else
local short = sha:sub(1, 8)
local date = format_author_time(commit.author_time, commit.author_tz)
sha_len = #short
date_col = sha_len + 2 + #commit.author + 2
head = {
short .. " " .. commit.author,
commit.author_mail .. " " .. relative_time(commit.author_time),
short .. " " .. commit.author .. " " .. date,
"",
}
end
@@ -809,7 +827,7 @@ local function open_popup(r, commits, line_sha, lnum, watch_buf)
style = "minimal",
})
popup_win = win
apply_popup(pbuf, win, head, body, sha_len)
apply_popup(pbuf, win, head, body, sha_len, date_col)
setup_popup_autocmds(watch_buf, pbuf, win)
if not sha_len then
return
@@ -828,7 +846,7 @@ local function open_popup(r, commits, line_sha, lnum, watch_buf)
end
local msg = util.split_lines(res.stdout or "")
if #msg > 0 then
apply_popup(pbuf, win, head, msg, sha_len)
apply_popup(pbuf, win, head, msg, sha_len, date_col)
end
end,
})
@@ -919,17 +937,17 @@ function M.detach(buf)
detach_autocmds(buf, state)
state.epoch = state.epoch + 1
states[buf] = nil
refresh_overlay_columns()
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_overlay_columns,
callback = refresh_gutter_columns,
})
-- The blame budget depends on the gutter option widths, so re-render an
-- overlay buffer when one of them changes.
-- gutter buffer when one of them changes.
vim.api.nvim_create_autocmd("OptionSet", {
group = augroup,
pattern = {
@@ -942,7 +960,7 @@ vim.api.nvim_create_autocmd("OptionSet", {
callback = function()
local buf = vim.api.nvim_get_current_buf()
local state = states[buf]
if state and state.overlay then
if state and state.gutter then
render(buf)
end
end,
@@ -960,7 +978,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.overlay then
if state.inline or state.gutter then
schedule(buf)
end
end