From db0b2d2527cd0daec5d8ca0665f4c82311985461 Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Tue, 26 May 2026 15:35:11 +0200 Subject: [PATCH] feat(git): add blame side window with synced scroll --- lua/core/keymap.lua | 1 + lua/git/blame.lua | 208 ++++++++++++++++++++++++++++++++++++++++ plugin/git.lua | 6 ++ test/git/blame_test.lua | 94 ++++++++++++++++++ 4 files changed, 309 insertions(+) diff --git a/lua/core/keymap.lua b/lua/core/keymap.lua index 890be47..413412b 100644 --- a/lua/core/keymap.lua +++ b/lua/core/keymap.lua @@ -235,6 +235,7 @@ vim.keymap.set("n", "gc", "(git-commit)") vim.keymap.set("n", "ga", "(git-commit-amend)") vim.keymap.set("n", "gl", "(git-log)") vim.keymap.set("n", "gb", "(git-blame-popup)") +vim.keymap.set("n", "gB", "(git-blame-view)") vim.keymap.set("n", "gv", "(git-hunk-select)") vim.keymap.set("n", "gs", "(git-hunk-stage-toggle)") vim.keymap.set("n", "gr", "(git-hunk-reset)") diff --git a/lua/git/blame.lua b/lua/git/blame.lua index 0aa34f8..399406e 100644 --- a/lua/git/blame.lua +++ b/lua/git/blame.lua @@ -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 +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", "", 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 diff --git a/plugin/git.lua b/plugin/git.lua index 4f467cf..4046029 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -322,6 +322,9 @@ vim.api.nvim_create_user_command("GitDiffOverlay", function() require("git.hunks").toggle_overlay() end, { desc = "Toggle the git diff overlay in the current buffer" }) +vim.keymap.set("n", "(git-blame-view)", function() + require("git.blame").toggle_view() +end, { silent = true, desc = "Toggle the git blame side window" }) vim.keymap.set("n", "(git-blame-line)", function() require("git.blame").toggle_inline() end, { silent = true, desc = "Toggle inline git blame" }) @@ -341,6 +344,9 @@ end, { desc = "Open this file at the parent of the line's commit", }) +vim.api.nvim_create_user_command("GitBlame", function() + require("git.blame").toggle_view() +end, { desc = "Toggle the git blame side window" }) vim.api.nvim_create_user_command("GitBlameLine", function() require("git.blame").toggle_inline() end, { desc = "Toggle inline git blame in the current buffer" }) diff --git a/test/git/blame_test.lua b/test/git/blame_test.lua index 5d59423..4df3004 100644 --- a/test/git/blame_test.lua +++ b/test/git/blame_test.lua @@ -197,6 +197,7 @@ t.test("blame actions are no-ops off a worktree", function() t.quietly(function() blame.line_popup(buf) blame.toggle_inline(buf) + blame.toggle_view(buf) end) t.eq(blame.state(buf), nil, "no state created for a non-worktree buffer") end) @@ -300,6 +301,99 @@ t.test("inline annotation follows the cursor", function() t.eq(assert(inline_marks(buf)[1])[2], 2, "annotation moved to line 3") end) +---@param buf integer +---@return integer view_win +local function wait_view(buf) + local win ---@type integer? + t.wait_for(function() + for _, w in ipairs(vim.api.nvim_list_wins()) do + local b = vim.api.nvim_win_get_buf(w) + if b ~= buf and vim.bo[b].filetype == "gitblame" then + win = w + return true + end + end + return false + end, "the blame side window to open") + return (assert(win)) +end + +t.test("toggle_view opens and closes a side split", function() + local _, buf = setup("alpha\nbeta\ngamma\n") + vim.api.nvim_set_current_buf(buf) + blame.toggle_view(buf) + local view_win = wait_view(buf) + t.defer(function() + if vim.api.nvim_win_is_valid(view_win) then + vim.api.nvim_win_close(view_win, true) + end + end) + blame.toggle_view(buf) + t.falsy( + vim.api.nvim_win_is_valid(view_win), + "toggling again closes the view" + ) +end) + +t.test("the side view shows sha, author and an absolute date", function() + local dir, buf = setup("alpha\nbeta\ngamma\n") + local sha = h.git(dir, "rev-parse", "HEAD").stdout + vim.api.nvim_set_current_buf(buf) + blame.toggle_view(buf) + local view_win = wait_view(buf) + t.defer(function() + if vim.api.nvim_win_is_valid(view_win) then + vim.api.nvim_win_close(view_win, true) + end + end) + local view_buf = vim.api.nvim_win_get_buf(view_win) + local row = vim.api.nvim_buf_get_lines(view_buf, 0, 1, false)[1] or "" + t.truthy( + vim.startswith(row, sha:sub(1, 8)), + "the row starts with the short sha" + ) + t.truthy(row:find("t", 1, true), "the row includes the author") + t.truthy( + row:match("%d%d%d%d%-%d%d%-%d%d$"), + "the row ends with a YYYY-MM-DD date" + ) +end) + +t.test(" on the view row opens the commit", function() + local _, buf = setup("alpha\nbeta\ngamma\n") + vim.api.nvim_set_current_buf(buf) + blame.toggle_view(buf) + local view_win = wait_view(buf) + t.defer(function() + if vim.api.nvim_win_is_valid(view_win) then + vim.api.nvim_win_close(view_win, true) + end + end) + vim.api.nvim_set_current_win(view_win) + vim.api.nvim_win_set_cursor(view_win, { 1, 0 }) + t.press("") + wait_buf_name("^git://%x+$") +end) + +t.test("closing the source window tears down the view", function() + local _, buf = setup("alpha\nbeta\ngamma\n") + vim.api.nvim_set_current_buf(buf) + local source_win = vim.api.nvim_get_current_win() + vim.cmd("split") + vim.api.nvim_set_current_win(source_win) + blame.toggle_view(buf) + local view_win = wait_view(buf) + t.defer(function() + if vim.api.nvim_win_is_valid(view_win) then + vim.api.nvim_win_close(view_win, true) + end + end) + vim.api.nvim_win_close(source_win, true) + t.wait_for(function() + return not vim.api.nvim_win_is_valid(view_win) + end, "the view to close when the source window closes") +end) + t.test("open_commit opens the commit that last touched the line", function() local _, buf = setup("alpha\nbeta\ngamma\n") vim.api.nvim_set_current_buf(buf)