feat(blame): show commit's hunk in the line popup
This commit is contained in:
+215
-56
@@ -1,10 +1,12 @@
|
||||
local object = require("git.object")
|
||||
local popup = require("git.core.popup")
|
||||
local repo = require("git.core.repo")
|
||||
local util = require("git.core.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
local NS_POPUP = vim.api.nvim_create_namespace("ow.git.blame.popup")
|
||||
local NS_OVERFLOW = vim.api.nvim_create_namespace("ow.git.blame.popup.overflow")
|
||||
|
||||
local ZERO_SHA = string.rep("0", 40)
|
||||
|
||||
@@ -19,6 +21,8 @@ local ZERO_SHA = string.rep("0", 40)
|
||||
---@class ow.Git.Blame.Result
|
||||
---@field commits table<string, ow.Git.Blame.Commit>
|
||||
---@field line_sha table<integer, string>
|
||||
---@field line_orig table<integer, integer>
|
||||
---@field line_file table<integer, string>
|
||||
|
||||
---@class ow.Git.Blame.Source
|
||||
---@field repo ow.Git.Repo
|
||||
@@ -31,6 +35,8 @@ local ZERO_SHA = string.rep("0", 40)
|
||||
---@field revision string?
|
||||
---@field commits table<string, ow.Git.Blame.Commit>
|
||||
---@field line_sha table<integer, string>
|
||||
---@field line_orig table<integer, integer>
|
||||
---@field line_file table<integer, string>
|
||||
---@field tick integer?
|
||||
---@field epoch integer
|
||||
---@field pending fun()[]
|
||||
@@ -72,20 +78,33 @@ local function parse_porcelain(stdout)
|
||||
local commits = {}
|
||||
---@type table<integer, string>
|
||||
local line_sha = {}
|
||||
---@type table<integer, integer>
|
||||
local line_orig = {}
|
||||
---@type table<integer, string>
|
||||
local line_file = {}
|
||||
local cur_sha ---@type string?
|
||||
local cur_lnum ---@type integer?
|
||||
local cur_orig ---@type integer?
|
||||
local cur_file ---@type string?
|
||||
for _, line in ipairs(util.split_lines(stdout)) do
|
||||
if line:sub(1, 1) == "\t" then
|
||||
if cur_sha and cur_lnum then
|
||||
if cur_sha and cur_lnum and cur_orig then
|
||||
line_sha[cur_lnum] = cur_sha
|
||||
line_orig[cur_lnum] = cur_orig
|
||||
if cur_file then
|
||||
line_file[cur_lnum] = cur_file
|
||||
end
|
||||
end
|
||||
cur_sha = nil
|
||||
cur_lnum = nil
|
||||
cur_orig = nil
|
||||
cur_file = nil
|
||||
else
|
||||
local sha, final = line:match("^(%x+) %d+ (%d+)")
|
||||
local sha, orig, final = line:match("^(%x+) (%d+) (%d+)")
|
||||
if sha and #sha >= 40 then
|
||||
cur_sha = sha
|
||||
cur_lnum = tonumber(final) --[[@as integer?]]
|
||||
cur_orig = tonumber(orig) --[[@as integer]]
|
||||
cur_lnum = tonumber(final) --[[@as integer]]
|
||||
if not commits[sha] then
|
||||
commits[sha] = {
|
||||
sha = sha,
|
||||
@@ -99,7 +118,9 @@ local function parse_porcelain(stdout)
|
||||
else
|
||||
local key, value = line:match("^(%S+) (.*)$")
|
||||
local commit = cur_sha and commits[cur_sha]
|
||||
if commit and key then
|
||||
if key == "filename" and value then
|
||||
cur_file = value
|
||||
elseif commit and key then
|
||||
if key == "author" then
|
||||
commit.author = value
|
||||
elseif key == "author-mail" then
|
||||
@@ -115,7 +136,12 @@ local function parse_porcelain(stdout)
|
||||
end
|
||||
end
|
||||
end
|
||||
return { commits = commits, line_sha = line_sha }
|
||||
return {
|
||||
commits = commits,
|
||||
line_sha = line_sha,
|
||||
line_orig = line_orig,
|
||||
line_file = line_file,
|
||||
}
|
||||
end
|
||||
|
||||
---@param line_count integer
|
||||
@@ -138,6 +164,8 @@ local function synth_uncommitted(line_count)
|
||||
},
|
||||
},
|
||||
line_sha = line_sha,
|
||||
line_orig = {},
|
||||
line_file = {},
|
||||
}
|
||||
end
|
||||
|
||||
@@ -219,6 +247,8 @@ local function ensure_state(buf)
|
||||
revision = src.revision,
|
||||
commits = {},
|
||||
line_sha = {},
|
||||
line_orig = {},
|
||||
line_file = {},
|
||||
tick = nil,
|
||||
epoch = 0,
|
||||
pending = {},
|
||||
@@ -266,6 +296,8 @@ local function run_blame(state, buf, done)
|
||||
or synth_uncommitted(vim.api.nvim_buf_line_count(buf))
|
||||
state.commits = data.commits
|
||||
state.line_sha = data.line_sha
|
||||
state.line_orig = data.line_orig
|
||||
state.line_file = data.line_file
|
||||
state.tick = tick
|
||||
local pending = state.pending
|
||||
state.pending = {}
|
||||
@@ -275,20 +307,101 @@ local function run_blame(state, buf, done)
|
||||
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
|
||||
local NEW_FILE_MARKER = "(new file)"
|
||||
|
||||
---@param diff_text string
|
||||
---@param orig_line integer
|
||||
---@return string[]?
|
||||
local function extract_commit_hunk(diff_text, orig_line)
|
||||
local lines = util.split_lines(diff_text)
|
||||
for _, line in ipairs(lines) do
|
||||
if line == "--- /dev/null" then
|
||||
return { NEW_FILE_MARKER }
|
||||
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
|
||||
local hunk_starts = {} ---@type integer[]
|
||||
local target ---@type integer?
|
||||
for i, line in ipairs(lines) do
|
||||
local c, d = line:match("^@@ %-%d+,?%d* %+(%d+),?(%d*) @@")
|
||||
if c then
|
||||
table.insert(hunk_starts, i)
|
||||
if not target then
|
||||
local ns = tonumber(c) --[[@as integer]]
|
||||
local nc = (d == "" or d == nil) and 1 or tonumber(d) --[[@as integer]]
|
||||
if orig_line >= ns and orig_line < ns + nc then
|
||||
target = #hunk_starts
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not target then
|
||||
return nil
|
||||
end
|
||||
local out = {} ---@type string[]
|
||||
table.insert(out, ("Hunk %d of %d"):format(target, #hunk_starts))
|
||||
table.insert(out, "")
|
||||
local start_idx = hunk_starts[target]
|
||||
local end_idx = hunk_starts[target + 1] and (hunk_starts[target + 1] - 1)
|
||||
or #lines
|
||||
for j = start_idx, end_idx do
|
||||
local line = lines[j] --[[@as string]]
|
||||
if line:match("^diff %-%-git") then
|
||||
break
|
||||
end
|
||||
table.insert(out, line)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local POPUP_SEP = "GIT_BLAME_POPUP_SEP"
|
||||
|
||||
---@param r ow.Git.Repo
|
||||
---@param sha string
|
||||
---@param rel string
|
||||
---@param orig_line integer
|
||||
---@param done fun(message: string[]?, hunk: string[]?)
|
||||
local function fetch_show(r, sha, rel, orig_line, done)
|
||||
util.git({
|
||||
"show",
|
||||
"--diff-merges=first-parent",
|
||||
"--format=%B%n" .. POPUP_SEP .. "%n",
|
||||
sha,
|
||||
"--",
|
||||
rel,
|
||||
}, {
|
||||
cwd = r.worktree,
|
||||
silent = true,
|
||||
on_exit = function(res)
|
||||
if res.code ~= 0 then
|
||||
done(nil, nil)
|
||||
return
|
||||
end
|
||||
local lines = util.split_lines(res.stdout or "")
|
||||
local sep_idx ---@type integer?
|
||||
for i, line in ipairs(lines) do
|
||||
if line == POPUP_SEP then
|
||||
sep_idx = i
|
||||
break
|
||||
end
|
||||
end
|
||||
if not sep_idx then
|
||||
done(nil, nil)
|
||||
return
|
||||
end
|
||||
local message = {} ---@type string[]
|
||||
for i = 1, sep_idx - 1 do
|
||||
table.insert(message, lines[i])
|
||||
end
|
||||
while #message > 0 and message[#message] == "" do
|
||||
table.remove(message)
|
||||
end
|
||||
local diff_text = table.concat(lines, "\n", sep_idx + 1) ---@type string
|
||||
done(
|
||||
#message > 0 and message or nil,
|
||||
extract_commit_hunk(diff_text, orig_line)
|
||||
)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local popup_win ---@type integer?
|
||||
@@ -303,14 +416,23 @@ end
|
||||
---@param pbuf integer
|
||||
---@param win integer
|
||||
---@param head string[]
|
||||
---@param body string[]?
|
||||
---@param message string[]?
|
||||
---@param diff string[]?
|
||||
---@param sha_len integer?
|
||||
---@param date_col integer?
|
||||
local function apply_popup(pbuf, win, head, body, sha_len, date_col)
|
||||
local lines = {}
|
||||
local function apply_popup(pbuf, win, head, message, diff, sha_len, date_col)
|
||||
local lines = {} ---@type string[]
|
||||
vim.list_extend(lines, head)
|
||||
if body then
|
||||
vim.list_extend(lines, body)
|
||||
if message then
|
||||
vim.list_extend(lines, message)
|
||||
end
|
||||
local diff_start ---@type integer?
|
||||
if diff then
|
||||
if #lines > #head then
|
||||
table.insert(lines, "")
|
||||
end
|
||||
diff_start = #lines
|
||||
vim.list_extend(lines, diff)
|
||||
end
|
||||
vim.bo[pbuf].modifiable = true
|
||||
vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines)
|
||||
@@ -334,9 +456,32 @@ local function apply_popup(pbuf, win, head, body, sha_len, date_col)
|
||||
hl_group = "GitBlameDate",
|
||||
})
|
||||
end
|
||||
local width, height = size_for(lines)
|
||||
if diff_start and diff then
|
||||
if #diff == 1 and diff[1] == NEW_FILE_MARKER then
|
||||
pcall(
|
||||
vim.api.nvim_buf_set_extmark,
|
||||
pbuf,
|
||||
NS_POPUP,
|
||||
diff_start,
|
||||
0,
|
||||
{ line_hl_group = "GitHunkAdded" }
|
||||
)
|
||||
else
|
||||
util.paint_diff_lines(pbuf, NS_POPUP, diff, diff_start)
|
||||
pcall(
|
||||
vim.api.nvim_buf_set_extmark,
|
||||
pbuf,
|
||||
NS_POPUP,
|
||||
diff_start,
|
||||
0,
|
||||
{ line_hl_group = "GitHunkAnnotation" }
|
||||
)
|
||||
end
|
||||
end
|
||||
local width, height = popup.size_for(lines)
|
||||
pcall(vim.api.nvim_win_set_width, win, width)
|
||||
pcall(vim.api.nvim_win_set_height, win, height)
|
||||
popup.paint_overflow(pbuf, win, NS_OVERFLOW)
|
||||
end
|
||||
|
||||
---@param watch_buf integer
|
||||
@@ -362,18 +507,23 @@ local function setup_popup_autocmds(watch_buf, pbuf, win)
|
||||
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.blame.popup")
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("WinScrolled", {
|
||||
group = group,
|
||||
pattern = tostring(win),
|
||||
callback = function()
|
||||
popup.paint_overflow(pbuf, win, NS_OVERFLOW)
|
||||
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 state ow.Git.Blame.BufState
|
||||
---@param lnum integer
|
||||
---@param watch_buf integer
|
||||
local function open_popup(r, commits, line_sha, lnum, watch_buf)
|
||||
local function open_popup(state, lnum, watch_buf)
|
||||
close_popup()
|
||||
local sha = line_sha[lnum]
|
||||
local commit = sha and commits[sha]
|
||||
local sha = state.line_sha[lnum]
|
||||
local commit = sha and state.commits[sha]
|
||||
if not commit then
|
||||
util.warning("git blame: no blame information for line %d", lnum)
|
||||
return
|
||||
@@ -393,15 +543,16 @@ local function open_popup(r, commits, line_sha, lnum, watch_buf)
|
||||
"",
|
||||
}
|
||||
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 message = sha_len and { commit.summary } or nil ---@type string[]?
|
||||
local diff ---@type string[]?
|
||||
local pbuf = vim.api.nvim_create_buf(false, true)
|
||||
vim.bo[pbuf].bufhidden = "wipe"
|
||||
local initial = {} ---@type string[]
|
||||
vim.list_extend(initial, head)
|
||||
if message then
|
||||
vim.list_extend(initial, message)
|
||||
end
|
||||
local width, height = popup.size_for(initial)
|
||||
local win = vim.api.nvim_open_win(pbuf, false, {
|
||||
relative = "cursor",
|
||||
row = 1,
|
||||
@@ -411,29 +562,37 @@ local function open_popup(r, commits, line_sha, lnum, watch_buf)
|
||||
style = "minimal",
|
||||
})
|
||||
popup_win = win
|
||||
apply_popup(pbuf, win, head, body, sha_len, date_col)
|
||||
|
||||
local function redraw()
|
||||
apply_popup(pbuf, win, head, message, diff, sha_len, date_col)
|
||||
end
|
||||
|
||||
redraw()
|
||||
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, date_col)
|
||||
end
|
||||
end,
|
||||
})
|
||||
local orig = state.line_orig[lnum]
|
||||
local rel = state.line_file[lnum] or state.rel
|
||||
if not orig then
|
||||
return
|
||||
end
|
||||
fetch_show(state.repo, sha, rel, orig, function(msg, hunk)
|
||||
if
|
||||
popup_win ~= win
|
||||
or not vim.api.nvim_win_is_valid(win)
|
||||
or not vim.api.nvim_buf_is_valid(pbuf)
|
||||
then
|
||||
return
|
||||
end
|
||||
if msg then
|
||||
message = msg
|
||||
end
|
||||
if hunk then
|
||||
diff = hunk
|
||||
end
|
||||
redraw()
|
||||
end)
|
||||
end
|
||||
|
||||
---@param buf integer?
|
||||
@@ -457,7 +616,7 @@ function M.line_popup(buf)
|
||||
then
|
||||
return
|
||||
end
|
||||
open_popup(state.repo, state.commits, state.line_sha, lnum, buf)
|
||||
open_popup(state, lnum, buf)
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
local M = {}
|
||||
|
||||
---@param win integer
|
||||
---@return boolean
|
||||
local function has_border(win)
|
||||
local b = vim.api.nvim_win_get_config(win).border
|
||||
if not b or b == "none" or b == "" then
|
||||
return false
|
||||
end
|
||||
if type(b) == "table" and #b == 0 then
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
---@param lines string[]
|
||||
---@param opts? { min_width?: integer, padding?: integer }
|
||||
---@return integer width
|
||||
---@return integer height
|
||||
function M.size_for(lines, opts)
|
||||
opts = opts or {}
|
||||
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 + (opts.padding or 1), opts.min_width or 30),
|
||||
100,
|
||||
vim.o.columns - 4
|
||||
)
|
||||
local height =
|
||||
math.min(math.max(#lines, 1), 20, math.floor(vim.o.lines / 2))
|
||||
return width, height
|
||||
end
|
||||
|
||||
---@param pbuf integer
|
||||
---@param win integer
|
||||
---@param ns integer
|
||||
function M.paint_overflow(pbuf, win, ns)
|
||||
vim.schedule(function()
|
||||
if
|
||||
not vim.api.nvim_buf_is_valid(pbuf)
|
||||
or not vim.api.nvim_win_is_valid(win)
|
||||
then
|
||||
return
|
||||
end
|
||||
vim.api.nvim_buf_clear_namespace(pbuf, ns, 0, -1)
|
||||
local total = vim.api.nvim_buf_line_count(pbuf)
|
||||
local top, bottom = unpack(vim.api.nvim_win_call(win, function()
|
||||
return { vim.fn.line("w0"), vim.fn.line("w$") }
|
||||
end)) ---@cast top integer
|
||||
---@cast bottom integer
|
||||
local needs_top = top > 1
|
||||
local needs_bottom = bottom < total
|
||||
if has_border(win) then
|
||||
pcall(vim.api.nvim_win_set_config, win, {
|
||||
title = needs_top and { { "▲ ", "FloatBorder" } } or "",
|
||||
title_pos = needs_top and "right" or nil,
|
||||
footer = needs_bottom and { { "▼ ", "FloatBorder" } } or "",
|
||||
footer_pos = needs_bottom and "right" or nil,
|
||||
})
|
||||
return
|
||||
end
|
||||
if needs_top then
|
||||
pcall(vim.api.nvim_buf_set_extmark, pbuf, ns, top - 1, 0, {
|
||||
virt_text = { { "▲ ", "GitPopupEnd" } },
|
||||
virt_text_pos = "right_align",
|
||||
})
|
||||
end
|
||||
if needs_bottom then
|
||||
pcall(vim.api.nvim_buf_set_extmark, pbuf, ns, bottom - 1, 0, {
|
||||
virt_text = { { "▼ ", "GitPopupEnd" } },
|
||||
virt_text_pos = "right_align",
|
||||
})
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -120,6 +120,34 @@ function M.split_lines(content)
|
||||
return lines
|
||||
end
|
||||
|
||||
---@param buf integer
|
||||
---@param ns integer
|
||||
---@param lines string[]
|
||||
---@param start_row integer 0-indexed row of the first line in `lines`
|
||||
function M.paint_diff_lines(buf, ns, lines, start_row)
|
||||
for i, line in ipairs(lines) do
|
||||
local hl ---@type string?
|
||||
local prefix = line:sub(1, 1)
|
||||
if prefix == "+" then
|
||||
hl = "GitHunkAdded"
|
||||
elseif prefix == "-" then
|
||||
hl = "GitHunkRemoved"
|
||||
elseif vim.startswith(line, "@@") then
|
||||
hl = "GitHunkHeader"
|
||||
end
|
||||
if hl then
|
||||
pcall(
|
||||
vim.api.nvim_buf_set_extmark,
|
||||
buf,
|
||||
ns,
|
||||
start_row + i - 1,
|
||||
0,
|
||||
{ line_hl_group = hl }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@class ow.Git.Util.DebounceHandle
|
||||
---@field cancel fun()
|
||||
---@field flush fun()
|
||||
|
||||
+14
-9
@@ -1,3 +1,4 @@
|
||||
local popup = require("git.core.popup")
|
||||
local repo = require("git.core.repo")
|
||||
local util = require("git.core.util")
|
||||
|
||||
@@ -5,6 +6,9 @@ local M = {}
|
||||
|
||||
local NS_SIGNS = vim.api.nvim_create_namespace("ow.git.hunks")
|
||||
local NS_OVERLAY = vim.api.nvim_create_namespace("ow.git.hunks.overlay")
|
||||
local NS_PREVIEW = vim.api.nvim_create_namespace("ow.git.hunks.preview")
|
||||
local NS_PREVIEW_OVERFLOW =
|
||||
vim.api.nvim_create_namespace("ow.git.hunks.preview.overflow")
|
||||
|
||||
---@alias ow.Git.Hunks.HunkType "add"|"change"|"delete"
|
||||
|
||||
@@ -905,16 +909,9 @@ function M.preview_hunk(buf)
|
||||
local lines = hunk_body(h)
|
||||
local pbuf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(pbuf, 0, -1, false, lines)
|
||||
vim.bo[pbuf].filetype = "diff"
|
||||
vim.bo[pbuf].bufhidden = "wipe"
|
||||
local width = 0
|
||||
for _, l in ipairs(lines) do
|
||||
if #l > width then
|
||||
width = #l
|
||||
end
|
||||
end
|
||||
width = math.min(math.max(width + 2, 40), vim.o.columns - 4)
|
||||
local height = math.min(#lines, math.floor(vim.o.lines / 2))
|
||||
util.paint_diff_lines(pbuf, NS_PREVIEW, lines, 0)
|
||||
local width, height = popup.size_for(lines, { min_width = 40, padding = 2 })
|
||||
local win = vim.api.nvim_open_win(pbuf, false, {
|
||||
relative = "cursor",
|
||||
row = 1,
|
||||
@@ -924,6 +921,7 @@ function M.preview_hunk(buf)
|
||||
style = "minimal",
|
||||
})
|
||||
preview_win = win
|
||||
popup.paint_overflow(pbuf, win, NS_PREVIEW_OVERFLOW)
|
||||
|
||||
local function close()
|
||||
if vim.api.nvim_win_is_valid(win) then
|
||||
@@ -949,6 +947,13 @@ function M.preview_hunk(buf)
|
||||
pcall(vim.api.nvim_del_augroup_by_name, "ow.git.hunks.preview")
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("WinScrolled", {
|
||||
group = group,
|
||||
pattern = tostring(win),
|
||||
callback = function()
|
||||
popup.paint_overflow(pbuf, win, NS_PREVIEW_OVERFLOW)
|
||||
end,
|
||||
})
|
||||
vim.keymap.set("n", "q", close, { buffer = pbuf, nowait = true })
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user