feat(git): add blame side window with synced scroll

This commit is contained in:
2026-05-26 15:35:11 +02:00
parent ebfa15c276
commit db0b2d2527
4 changed files with 309 additions and 0 deletions
+1
View File
@@ -235,6 +235,7 @@ 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-view)")
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)")
+208
View File
@@ -6,8 +6,14 @@ 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 NS_VIEW = vim.api.nvim_create_namespace("ow.git.blame.view")
local ZERO_SHA = string.rep("0", 40)
local SHA_W = 8
local AUTHOR_W = 20
local DATE_W = 10
local GAP = " "
local VIEW_WIDTH = SHA_W + #GAP + AUTHOR_W + #GAP + DATE_W
---@class ow.Git.Blame.Commit
---@field sha string
@@ -441,6 +447,207 @@ function M.toggle_inline(buf)
end
end
---@class ow.Git.Blame.View
---@field source_buf integer
---@field source_win integer
---@field view_buf integer
---@field view_win integer
---@field group integer
---@type table<integer, ow.Git.Blame.View>
local views = {}
---@param s string
---@param w integer
---@return string
local function pad_w(s, w)
local sw = vim.api.nvim_strwidth(s)
if sw > w then
return vim.fn.strcharpart(s, 0, w)
end
return s .. string.rep(" ", w - sw)
end
---@param state ow.Git.Blame.BufState
---@param line_count integer
---@return string[]
local function build_view_lines(state, line_count)
local lines = {}
for lnum = 1, line_count do
local sha = state.line_sha[lnum]
local commit = sha and state.commits[sha]
if commit and not util.is_zero_sha(sha) then
local author = pad_w(commit.author, AUTHOR_W)
local date = os.date("%Y-%m-%d", commit.author_time)
lines[lnum] = sha:sub(1, SHA_W) .. GAP .. author .. GAP .. date
else
lines[lnum] = pad_w("Uncommitted", VIEW_WIDTH)
end
end
return lines
end
---@param view ow.Git.Blame.View
---@param state ow.Git.Blame.BufState
local function render_view(view, state)
if not vim.api.nvim_buf_is_valid(view.view_buf) then
return
end
local line_count = vim.api.nvim_buf_line_count(view.source_buf)
local lines = build_view_lines(state, line_count)
util.set_buf_lines(view.view_buf, 0, -1, lines)
vim.api.nvim_buf_clear_namespace(view.view_buf, NS_VIEW, 0, -1)
local author_col = SHA_W + #GAP
local date_col = author_col + AUTHOR_W + #GAP
for lnum = 1, #lines do
local sha = state.line_sha[lnum]
if sha and not util.is_zero_sha(sha) then
pcall(vim.api.nvim_buf_set_extmark, view.view_buf, NS_VIEW, lnum - 1, 0, {
end_col = SHA_W,
hl_group = "GitBlameSha",
})
pcall(vim.api.nvim_buf_set_extmark, view.view_buf, NS_VIEW, lnum - 1, author_col, {
end_col = author_col + AUTHOR_W,
hl_group = "GitBlameAuthor",
})
pcall(vim.api.nvim_buf_set_extmark, view.view_buf, NS_VIEW, lnum - 1, date_col, {
end_col = date_col + DATE_W,
hl_group = "GitBlameDate",
})
end
end
end
---@param source_buf integer
local function close_view(source_buf)
local view = views[source_buf]
if not view then
return
end
views[source_buf] = nil
pcall(vim.api.nvim_del_augroup_by_id, view.group)
if vim.api.nvim_win_is_valid(view.source_win) then
vim.wo[view.source_win].scrollbind = false
vim.wo[view.source_win].cursorbind = false
end
if vim.api.nvim_win_is_valid(view.view_win) then
pcall(vim.api.nvim_win_close, view.view_win, true)
end
end
---@param source_buf integer
---@param state ow.Git.Blame.BufState
---@param source_win integer
local function open_view(source_buf, state, source_win)
local view_buf = vim.api.nvim_create_buf(false, true)
util.setup_scratch(view_buf, { buftype = "nofile", bufhidden = "wipe" })
vim.bo[view_buf].filetype = "gitblame"
local view_win = vim.api.nvim_open_win(view_buf, false, {
split = "left",
width = VIEW_WIDTH,
win = source_win,
})
vim.api.nvim_set_option_value("number", false, { win = view_win, scope = "local" })
vim.api.nvim_set_option_value("relativenumber", false, { win = view_win, scope = "local" })
vim.api.nvim_set_option_value("signcolumn", "no", { win = view_win, scope = "local" })
vim.api.nvim_set_option_value("foldcolumn", "0", { win = view_win, scope = "local" })
vim.api.nvim_set_option_value("wrap", false, { win = view_win, scope = "local" })
vim.api.nvim_set_option_value("cursorline", true, { win = view_win, scope = "local" })
vim.api.nvim_set_option_value("winfixwidth", true, { win = view_win, scope = "local" })
vim.api.nvim_set_option_value("statuscolumn", "", { win = view_win, scope = "local" })
if not vim.tbl_contains(vim.opt.scrollopt:get(), "ver") then
vim.opt.scrollopt:append("ver")
end
vim.wo[source_win].scrollbind = true
vim.wo[source_win].cursorbind = true
vim.wo[view_win].scrollbind = true
vim.wo[view_win].cursorbind = true
local group = vim.api.nvim_create_augroup(
"ow.git.blame.view." .. source_buf,
{ clear = true }
)
---@type ow.Git.Blame.View
local view = {
source_buf = source_buf,
source_win = source_win,
view_buf = view_buf,
view_win = view_win,
group = group,
}
views[source_buf] = view
render_view(view, state)
vim.api.nvim_win_call(view_win, function()
vim.cmd("syncbind")
end)
vim.api.nvim_create_autocmd("WinClosed", {
group = group,
callback = function(args)
local closed = tonumber(args.match)
if closed == view_win or closed == source_win then
vim.schedule(function()
close_view(source_buf)
end)
end
end,
})
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
group = group,
buffer = view_buf,
callback = function()
vim.schedule(function()
close_view(source_buf)
end)
end,
})
vim.keymap.set("n", "<CR>", function()
local lnum = vim.api.nvim_win_get_cursor(view_win)[1]
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
object.open(state.repo, sha, { split = false })
end, { buffer = view_buf, silent = true, desc = "Open the commit" })
vim.keymap.set("n", "q", function()
close_view(source_buf)
end, { buffer = view_buf, silent = true, desc = "Close the blame view" })
end
---@param buf integer?
function M.toggle_view(buf)
buf = resolve_buf(buf)
if views[buf] then
close_view(buf)
return
end
local state = ensure_state(buf)
if not state then
util.warning("git blame: nothing to blame in this buffer")
return
end
local source_win = vim.api.nvim_get_current_win()
if vim.api.nvim_win_get_buf(source_win) ~= buf then
source_win = vim.fn.bufwinid(buf)
end
if source_win == -1 then
return
end
run_blame(state, buf, function()
if
not vim.api.nvim_buf_is_valid(buf)
or not vim.api.nvim_win_is_valid(source_win)
then
return
end
open_view(buf, state, source_win)
end)
end
---@param lines string[]
---@return integer width
---@return integer height
@@ -677,6 +884,7 @@ end
---@param buf integer
function M.detach(buf)
close_view(buf)
local state = states[buf]
if not state then
return