feat(git): add in-house git blame
This commit is contained in:
@@ -0,0 +1,953 @@
|
||||
local object = require("git.object")
|
||||
local repo = require("git.core.repo")
|
||||
local util = require("git.core.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
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
|
||||
---@field author string
|
||||
---@field author_mail string
|
||||
---@field author_time integer
|
||||
---@field author_tz string
|
||||
---@field summary string
|
||||
|
||||
---@class ow.Git.Blame.Result
|
||||
---@field commits table<string, ow.Git.Blame.Commit>
|
||||
---@field line_sha table<integer, string>
|
||||
|
||||
---@class ow.Git.Blame.Source
|
||||
---@field repo ow.Git.Repo
|
||||
---@field rel string
|
||||
---@field revision string?
|
||||
|
||||
---@class ow.Git.Blame.BufState
|
||||
---@field repo ow.Git.Repo
|
||||
---@field rel string
|
||||
---@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 tick integer?
|
||||
---@field epoch integer
|
||||
---@field pending fun()[]
|
||||
---@field inline boolean
|
||||
---@field overlay boolean
|
||||
---@field autocmds integer[]
|
||||
|
||||
---@type table<integer, ow.Git.Blame.BufState>
|
||||
local states = {}
|
||||
|
||||
---@param buf integer?
|
||||
---@return integer
|
||||
local function resolve_buf(buf)
|
||||
return buf and buf ~= 0 and buf or vim.api.nvim_get_current_buf()
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@return ow.Git.Blame.BufState?
|
||||
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)
|
||||
local diff = os.time() - unix_ts
|
||||
if diff < 0 then
|
||||
diff = 0
|
||||
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")
|
||||
elseif diff < 90 * 60 then
|
||||
return "an hour ago"
|
||||
elseif diff < 22 * 3600 then
|
||||
return plural(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")
|
||||
elseif diff < 30 * 86400 then
|
||||
return plural(math.floor(diff / (7 * 86400) + 0.5), "week")
|
||||
elseif diff < 365 * 86400 then
|
||||
return plural(math.floor(diff / (30 * 86400) + 0.5), "month")
|
||||
end
|
||||
return plural(math.floor(diff / (365 * 86400) + 0.5), "year")
|
||||
end
|
||||
|
||||
M.relative_time = relative_time
|
||||
|
||||
---@param ts integer
|
||||
---@return string
|
||||
local function format_date(ts)
|
||||
return os.date("%Y-%m-%d", ts) --[[@as string]]
|
||||
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)
|
||||
---@type table<string, ow.Git.Blame.Commit>
|
||||
local commits = {}
|
||||
---@type table<integer, string>
|
||||
local line_sha = {}
|
||||
local cur_sha ---@type string?
|
||||
local cur_lnum ---@type integer?
|
||||
for _, line in ipairs(util.split_lines(stdout)) do
|
||||
if line:sub(1, 1) == "\t" then
|
||||
if cur_sha and cur_lnum then
|
||||
line_sha[cur_lnum] = cur_sha
|
||||
end
|
||||
cur_sha = nil
|
||||
cur_lnum = nil
|
||||
else
|
||||
local sha, final = line:match("^(%x+) %d+ (%d+)")
|
||||
if sha and #sha >= 40 then
|
||||
cur_sha = sha
|
||||
cur_lnum = tonumber(final) --[[@as integer?]]
|
||||
if not commits[sha] then
|
||||
commits[sha] = {
|
||||
sha = sha,
|
||||
author = "",
|
||||
author_mail = "",
|
||||
author_time = 0,
|
||||
author_tz = "",
|
||||
summary = "",
|
||||
}
|
||||
end
|
||||
else
|
||||
local key, value = line:match("^(%S+) (.*)$")
|
||||
local commit = cur_sha and commits[cur_sha]
|
||||
if commit and key then
|
||||
if key == "author" then
|
||||
commit.author = value
|
||||
elseif key == "author-mail" then
|
||||
commit.author_mail = value
|
||||
elseif key == "author-time" then
|
||||
commit.author_time = math.floor(tonumber(value) or 0)
|
||||
elseif key == "author-tz" then
|
||||
commit.author_tz = value
|
||||
elseif key == "summary" then
|
||||
commit.summary = value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return { commits = commits, line_sha = line_sha }
|
||||
end
|
||||
|
||||
---@param line_count integer
|
||||
---@return ow.Git.Blame.Result
|
||||
local function synth_uncommitted(line_count)
|
||||
---@type table<integer, string>
|
||||
local line_sha = {}
|
||||
for i = 1, line_count do
|
||||
line_sha[i] = ZERO_SHA
|
||||
end
|
||||
return {
|
||||
commits = {
|
||||
[ZERO_SHA] = {
|
||||
sha = ZERO_SHA,
|
||||
author = "Not Committed Yet",
|
||||
author_mail = "",
|
||||
author_time = os.time() --[[@as integer]],
|
||||
author_tz = "",
|
||||
summary = "",
|
||||
},
|
||||
},
|
||||
line_sha = line_sha,
|
||||
}
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param rel string
|
||||
---@param opts { rev: string?, contents: string? }
|
||||
---@param done fun(result: ow.Git.Blame.Result?)
|
||||
local function fetch_blame(r, rel, opts, done)
|
||||
local args = { "--no-pager", "blame", "--porcelain" }
|
||||
if opts.contents then
|
||||
table.insert(args, "--contents")
|
||||
table.insert(args, "-")
|
||||
end
|
||||
if opts.rev then
|
||||
table.insert(args, opts.rev)
|
||||
end
|
||||
table.insert(args, "--")
|
||||
table.insert(args, rel)
|
||||
util.git(args, {
|
||||
cwd = r.worktree,
|
||||
stdin = opts.contents,
|
||||
silent = true,
|
||||
on_exit = function(res)
|
||||
if res.code ~= 0 then
|
||||
done(nil)
|
||||
else
|
||||
done(parse_porcelain(res.stdout or ""))
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
---Work out what a buffer should be blamed against: a worktree file
|
||||
---(blame the buffer contents) or a `git://<rev>:<path>` object (blame
|
||||
---that revision). A `git://<rev>` object with no path is not blameable.
|
||||
---@param buf integer
|
||||
---@return ow.Git.Blame.Source?
|
||||
local function resolve_source(buf)
|
||||
if not vim.api.nvim_buf_is_valid(buf) then
|
||||
return nil
|
||||
end
|
||||
local name = vim.api.nvim_buf_get_name(buf)
|
||||
if util.is_uri(name) then
|
||||
local rev = object.parse_uri(name)
|
||||
if not rev or not rev.base or not rev.path then
|
||||
return nil
|
||||
end
|
||||
local r = repo.find(buf)
|
||||
if not r then
|
||||
return nil
|
||||
end
|
||||
return { repo = r, rel = rev.path, revision = rev.base }
|
||||
end
|
||||
if not repo.is_worktree_buf(buf) then
|
||||
return nil
|
||||
end
|
||||
local r = repo.find(buf)
|
||||
if not r then
|
||||
return nil
|
||||
end
|
||||
local rel = vim.fs.relpath(r.worktree, vim.fn.resolve(name))
|
||||
if not rel then
|
||||
return nil
|
||||
end
|
||||
return { repo = r, rel = rel }
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@return ow.Git.Blame.BufState?
|
||||
local function ensure_state(buf)
|
||||
if states[buf] then
|
||||
return states[buf]
|
||||
end
|
||||
local src = resolve_source(buf)
|
||||
if not src then
|
||||
return nil
|
||||
end
|
||||
---@type ow.Git.Blame.BufState
|
||||
local state = {
|
||||
repo = src.repo,
|
||||
rel = src.rel,
|
||||
revision = src.revision,
|
||||
commits = {},
|
||||
line_sha = {},
|
||||
blame_text = nil,
|
||||
blame_width = nil,
|
||||
blame_blank = nil,
|
||||
tick = nil,
|
||||
epoch = 0,
|
||||
pending = {},
|
||||
inline = false,
|
||||
overlay = false,
|
||||
autocmds = {},
|
||||
}
|
||||
states[buf] = state
|
||||
return state
|
||||
end
|
||||
|
||||
---Blame the buffer and cache the result, keyed by `changedtick`. Worktree
|
||||
---buffers blame the live buffer contents; `git://` buffers blame their
|
||||
---revision. `done` runs once the cache is populated.
|
||||
---@param state ow.Git.Blame.BufState
|
||||
---@param buf integer
|
||||
---@param done fun()?
|
||||
local function run_blame(state, buf, done)
|
||||
local tick = vim.api.nvim_buf_get_changedtick(buf)
|
||||
if state.tick == tick then
|
||||
if done then
|
||||
done()
|
||||
end
|
||||
return
|
||||
end
|
||||
if done then
|
||||
table.insert(state.pending, done)
|
||||
end
|
||||
state.epoch = state.epoch + 1
|
||||
local epoch = state.epoch
|
||||
local opts ---@type { rev: string?, contents: string? }
|
||||
if state.revision then
|
||||
opts = { rev = state.revision }
|
||||
else
|
||||
opts = {
|
||||
contents = table.concat(
|
||||
vim.api.nvim_buf_get_lines(buf, 0, -1, false),
|
||||
"\n"
|
||||
) .. "\n",
|
||||
}
|
||||
end
|
||||
fetch_blame(state.repo, state.rel, opts, function(result)
|
||||
if
|
||||
states[buf] ~= state
|
||||
or epoch ~= state.epoch
|
||||
or not vim.api.nvim_buf_is_valid(buf)
|
||||
then
|
||||
return
|
||||
end
|
||||
local data = result
|
||||
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 = {}
|
||||
for _, fn in ipairs(pending) do
|
||||
fn()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
local function render_inline(buf)
|
||||
if not vim.api.nvim_buf_is_valid(buf) then
|
||||
return
|
||||
end
|
||||
vim.api.nvim_buf_clear_namespace(buf, NS_INLINE, 0, -1)
|
||||
local state = states[buf]
|
||||
if not state or not state.inline then
|
||||
return
|
||||
end
|
||||
local win = vim.api.nvim_get_current_buf() == buf
|
||||
and vim.api.nvim_get_current_win()
|
||||
or vim.fn.bufwinid(buf)
|
||||
if win == -1 then
|
||||
return
|
||||
end
|
||||
local lnum = vim.api.nvim_win_get_cursor(win)[1]
|
||||
local sha = state.line_sha[lnum]
|
||||
local commit = sha and state.commits[sha]
|
||||
if not commit then
|
||||
return
|
||||
end
|
||||
local text
|
||||
if util.is_zero_sha(sha) then
|
||||
text = " You - Not Committed Yet"
|
||||
else
|
||||
text = string.format(
|
||||
" %s, %s - %s",
|
||||
commit.author,
|
||||
relative_time(commit.author_time),
|
||||
commit.summary
|
||||
)
|
||||
end
|
||||
pcall(vim.api.nvim_buf_set_extmark, buf, NS_INLINE, lnum - 1, 0, {
|
||||
virt_text = { { text, "GitBlame" } },
|
||||
virt_text_pos = "eol",
|
||||
hl_mode = "combine",
|
||||
})
|
||||
end
|
||||
|
||||
---The native fold / sign / number column items, each emitted only when
|
||||
---its window option is on.
|
||||
---@param win integer
|
||||
---@return string
|
||||
local function native_items(win)
|
||||
local wo = vim.wo[win]
|
||||
local items = ""
|
||||
if wo.foldcolumn ~= "0" then
|
||||
items = items .. "%C"
|
||||
end
|
||||
if wo.signcolumn ~= "no" then
|
||||
items = items .. "%s"
|
||||
end
|
||||
if wo.number or wo.relativenumber then
|
||||
items = items .. "%l"
|
||||
end
|
||||
return items
|
||||
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.
|
||||
---@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 = format_date(commit.author_time)
|
||||
end
|
||||
text[lnum] = "%#GitBlameSha#"
|
||||
.. sha:sub(1, sha_w)
|
||||
.. "%#GitBlame#"
|
||||
.. GAP
|
||||
.. (pad(author, author_w):gsub("%%", "%%%%"))
|
||||
.. 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 overlay 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.overlay or not state.blame_text then
|
||||
return ""
|
||||
end
|
||||
return (virtnum == 0 and state.blame_text[lnum]) or state.blame_blank or ""
|
||||
end
|
||||
|
||||
M._gutter = gutter
|
||||
M._layout = layout
|
||||
M._native_width = native_width
|
||||
|
||||
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 = {}
|
||||
|
||||
---Reconcile every window's `'statuscolumn'` with the overlay state: a
|
||||
---window showing an overlay buffer gets the blame statuscolumn, and its
|
||||
---previous value is saved so it can be restored on toggle-off.
|
||||
local function refresh_overlay_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.overlay
|
||||
if on and saved_statuscolumn[win] == nil then
|
||||
saved_statuscolumn[win] = vim.wo[win].statuscolumn
|
||||
vim.wo[win].statuscolumn = BLAME_EXPR .. native_items(win)
|
||||
elseif not on and saved_statuscolumn[win] ~= nil then
|
||||
vim.wo[win].statuscolumn = saved_statuscolumn[win]
|
||||
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.overlay 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
|
||||
vim.wo[win].statuscolumn = BLAME_EXPR .. native_items(win)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
local function reblame(buf)
|
||||
local state = states[buf]
|
||||
if not state or (not state.inline and not state.overlay) then
|
||||
return
|
||||
end
|
||||
run_blame(state, buf, function()
|
||||
render(buf)
|
||||
end)
|
||||
end
|
||||
|
||||
local schedule, sched_handle = util.keyed_debounce(reblame, 150)
|
||||
|
||||
---@param buf integer
|
||||
function M._flush(buf)
|
||||
sched_handle.flush(buf)
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@param state ow.Git.Blame.BufState
|
||||
local function attach_autocmds(buf, state)
|
||||
if #state.autocmds > 0 then
|
||||
return
|
||||
end
|
||||
local group =
|
||||
vim.api.nvim_create_augroup("ow.git.blame." .. buf, { clear = true })
|
||||
state.autocmds = {
|
||||
vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, {
|
||||
group = group,
|
||||
buffer = buf,
|
||||
callback = function()
|
||||
render_inline(buf)
|
||||
end,
|
||||
}),
|
||||
vim.api.nvim_create_autocmd(
|
||||
{ "TextChanged", "TextChangedI", "BufWritePost" },
|
||||
{
|
||||
group = group,
|
||||
buffer = buf,
|
||||
callback = function()
|
||||
schedule(buf)
|
||||
end,
|
||||
}
|
||||
),
|
||||
}
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@param state ow.Git.Blame.BufState
|
||||
local function detach_autocmds(buf, state)
|
||||
for _, id in ipairs(state.autocmds) do
|
||||
pcall(vim.api.nvim_del_autocmd, id)
|
||||
end
|
||||
state.autocmds = {}
|
||||
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame." .. buf)
|
||||
sched_handle.cancel(buf)
|
||||
end
|
||||
|
||||
---@param buf integer?
|
||||
function M.toggle_inline(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.inline = not state.inline
|
||||
if state.inline then
|
||||
attach_autocmds(buf, state)
|
||||
run_blame(state, buf, function()
|
||||
render_inline(buf)
|
||||
end)
|
||||
else
|
||||
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
|
||||
detach_autocmds(buf, state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param buf integer?
|
||||
function M.toggle_overlay(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
|
||||
attach_autocmds(buf, state)
|
||||
run_blame(state, buf, function()
|
||||
render(buf)
|
||||
end)
|
||||
elseif not state.inline then
|
||||
detach_autocmds(buf, state)
|
||||
end
|
||||
end
|
||||
|
||||
---@param lines string[]
|
||||
---@return integer width
|
||||
---@return integer height
|
||||
local function size_for(lines)
|
||||
local width = 1
|
||||
for _, l in ipairs(lines) do
|
||||
local w = vim.api.nvim_strwidth(l)
|
||||
if w > width then
|
||||
width = w
|
||||
end
|
||||
end
|
||||
width = math.min(math.max(width + 1, 30), vim.o.columns - 4)
|
||||
local height = math.min(math.max(#lines, 1), math.floor(vim.o.lines / 2))
|
||||
return width, height
|
||||
end
|
||||
|
||||
local popup_win ---@type integer?
|
||||
|
||||
local function close_popup()
|
||||
if popup_win and vim.api.nvim_win_is_valid(popup_win) then
|
||||
vim.api.nvim_win_close(popup_win, true)
|
||||
end
|
||||
popup_win = nil
|
||||
end
|
||||
|
||||
---@param pbuf integer
|
||||
---@param win integer
|
||||
---@param head string[]
|
||||
---@param body string[]?
|
||||
---@param sha_len integer?
|
||||
local function apply_popup(pbuf, win, head, body, sha_len)
|
||||
local lines = {}
|
||||
vim.list_extend(lines, head)
|
||||
if body then
|
||||
vim.list_extend(lines, body)
|
||||
end
|
||||
vim.bo[pbuf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines)
|
||||
vim.bo[pbuf].modifiable = false
|
||||
vim.api.nvim_buf_clear_namespace(pbuf, NS_POPUP, 0, -1)
|
||||
if sha_len then
|
||||
pcall(vim.api.nvim_buf_set_extmark, pbuf, NS_POPUP, 0, 0, {
|
||||
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
|
||||
local width, height = size_for(lines)
|
||||
pcall(vim.api.nvim_win_set_width, win, width)
|
||||
pcall(vim.api.nvim_win_set_height, win, height)
|
||||
end
|
||||
|
||||
---@param watch_buf integer
|
||||
---@param pbuf integer
|
||||
---@param win integer
|
||||
local function setup_popup_autocmds(watch_buf, pbuf, win)
|
||||
local group =
|
||||
vim.api.nvim_create_augroup("ow.git.blame.popup", { clear = true })
|
||||
vim.api.nvim_create_autocmd(
|
||||
{ "CursorMoved", "CursorMovedI", "InsertEnter" },
|
||||
{ group = group, buffer = watch_buf, callback = close_popup }
|
||||
)
|
||||
vim.api.nvim_create_autocmd("WinLeave", {
|
||||
group = group,
|
||||
buffer = pbuf,
|
||||
callback = close_popup,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("WinClosed", {
|
||||
group = group,
|
||||
pattern = tostring(win),
|
||||
callback = function()
|
||||
popup_win = nil
|
||||
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame.popup")
|
||||
end,
|
||||
})
|
||||
vim.keymap.set("n", "q", close_popup, { buffer = pbuf, nowait = true })
|
||||
end
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param commits table<string, ow.Git.Blame.Commit>
|
||||
---@param line_sha table<integer, string>
|
||||
---@param lnum integer
|
||||
---@param watch_buf integer
|
||||
local function open_popup(r, commits, line_sha, lnum, watch_buf)
|
||||
close_popup()
|
||||
local sha = line_sha[lnum]
|
||||
local commit = sha and commits[sha]
|
||||
if not commit then
|
||||
util.warning("git blame: no blame information for line %d", lnum)
|
||||
return
|
||||
end
|
||||
local head ---@type string[]
|
||||
local sha_len ---@type integer?
|
||||
if util.is_zero_sha(sha) then
|
||||
head = { "Not Committed Yet" }
|
||||
else
|
||||
local short = sha:sub(1, 8)
|
||||
sha_len = #short
|
||||
head = {
|
||||
short .. " " .. commit.author,
|
||||
commit.author_mail .. " " .. relative_time(commit.author_time),
|
||||
"",
|
||||
}
|
||||
end
|
||||
local body = sha_len and { commit.summary } or nil
|
||||
local lines = {}
|
||||
vim.list_extend(lines, head)
|
||||
if body then
|
||||
vim.list_extend(lines, body)
|
||||
end
|
||||
local width, height = size_for(lines)
|
||||
local pbuf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[pbuf].bufhidden = "wipe"
|
||||
local win = vim.api.nvim_open_win(pbuf, false, {
|
||||
relative = "cursor",
|
||||
row = 1,
|
||||
col = 0,
|
||||
width = width,
|
||||
height = height,
|
||||
style = "minimal",
|
||||
})
|
||||
popup_win = win
|
||||
apply_popup(pbuf, win, head, body, sha_len)
|
||||
setup_popup_autocmds(watch_buf, pbuf, win)
|
||||
if not sha_len then
|
||||
return
|
||||
end
|
||||
util.git({ "show", "-s", "--format=%B", sha }, {
|
||||
cwd = r.worktree,
|
||||
silent = true,
|
||||
on_exit = function(res)
|
||||
if
|
||||
popup_win ~= win
|
||||
or not vim.api.nvim_win_is_valid(win)
|
||||
or not vim.api.nvim_buf_is_valid(pbuf)
|
||||
or res.code ~= 0
|
||||
then
|
||||
return
|
||||
end
|
||||
local msg = util.split_lines(res.stdout or "")
|
||||
if #msg > 0 then
|
||||
apply_popup(pbuf, win, head, msg, sha_len)
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
---@param buf integer?
|
||||
function M.line_popup(buf)
|
||||
buf = resolve_buf(buf)
|
||||
if popup_win and vim.api.nvim_win_is_valid(popup_win) then
|
||||
vim.api.nvim_set_current_win(popup_win)
|
||||
return
|
||||
end
|
||||
local state = ensure_state(buf)
|
||||
if not state then
|
||||
util.warning("git blame: nothing to blame in this buffer")
|
||||
return
|
||||
end
|
||||
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
run_blame(state, buf, function()
|
||||
if
|
||||
not vim.api.nvim_buf_is_valid(buf)
|
||||
or vim.api.nvim_get_current_buf() ~= buf
|
||||
or vim.api.nvim_win_get_cursor(0)[1] ~= lnum
|
||||
then
|
||||
return
|
||||
end
|
||||
open_popup(state.repo, state.commits, state.line_sha, lnum, buf)
|
||||
end)
|
||||
end
|
||||
|
||||
---Blame the current line of the current buffer, then hand the commit to
|
||||
---`done`. Works in worktree files and `git://<rev>:<path>` buffers
|
||||
---alike, so the open-* actions chain through history.
|
||||
---@param done fun(state: ow.Git.Blame.BufState, sha: string)
|
||||
local function blame_line(done)
|
||||
local buf = vim.api.nvim_get_current_buf()
|
||||
local state = ensure_state(buf)
|
||||
if not state then
|
||||
util.warning("git blame: nothing to blame in this buffer")
|
||||
return
|
||||
end
|
||||
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
run_blame(state, buf, function()
|
||||
if not vim.api.nvim_buf_is_valid(buf) then
|
||||
return
|
||||
end
|
||||
local sha = state.line_sha[lnum]
|
||||
if not sha or util.is_zero_sha(sha) then
|
||||
util.warning("git blame: line is not committed yet")
|
||||
return
|
||||
end
|
||||
done(state, sha)
|
||||
end)
|
||||
end
|
||||
|
||||
function M.open_commit()
|
||||
blame_line(function(state, sha)
|
||||
object.open(state.repo, sha, { split = false })
|
||||
end)
|
||||
end
|
||||
|
||||
function M.open_file()
|
||||
blame_line(function(state, sha)
|
||||
object.open(state.repo, sha .. ":" .. state.rel, { split = false })
|
||||
end)
|
||||
end
|
||||
|
||||
function M.open_file_parent()
|
||||
blame_line(function(state, sha)
|
||||
local parent = state.repo:rev_parse(sha .. "^", false)
|
||||
if not parent then
|
||||
util.warning("git blame: %s has no parent commit", sha:sub(1, 8))
|
||||
return
|
||||
end
|
||||
object.open(state.repo, parent .. ":" .. state.rel, { split = false })
|
||||
end)
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
function M.detach(buf)
|
||||
local state = states[buf]
|
||||
if not state then
|
||||
return
|
||||
end
|
||||
if vim.api.nvim_buf_is_valid(buf) then
|
||||
vim.api.nvim_buf_clear_namespace(buf, NS_INLINE, 0, -1)
|
||||
end
|
||||
detach_autocmds(buf, state)
|
||||
state.epoch = state.epoch + 1
|
||||
states[buf] = nil
|
||||
refresh_overlay_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,
|
||||
})
|
||||
|
||||
-- The blame budget depends on the gutter option widths, so re-render an
|
||||
-- overlay 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.overlay 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
|
||||
-- is showing. `git://` buffers blame a fixed revision and are skipped.
|
||||
repo.on("change", function(r, change)
|
||||
for buf, state in pairs(states) do
|
||||
if
|
||||
state.repo == r
|
||||
and not state.revision
|
||||
and (change.paths[state.rel] or change.branch_changed)
|
||||
then
|
||||
state.tick = nil
|
||||
if state.inline or state.overlay then
|
||||
schedule(buf)
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user