feat(git): add blame side window with synced scroll
This commit is contained in:
@@ -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)")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "<Plug>(git-blame-view)", function()
|
||||
require("git.blame").toggle_view()
|
||||
end, { silent = true, desc = "Toggle the git blame side window" })
|
||||
vim.keymap.set("n", "<Plug>(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" })
|
||||
|
||||
@@ -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("<CR> 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("<CR>")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user