From ff7b20ec46f1117e0b4d6181b810fb658273158e Mon Sep 17 00:00:00 2001 From: Oscar Wallberg Date: Tue, 26 May 2026 23:25:08 +0200 Subject: [PATCH] feat(blame): show commit's hunk in the line popup --- lua/git/blame.lua | 271 +++++++++++++++++++++++++++++++--------- lua/git/core/popup.lua | 82 ++++++++++++ lua/git/core/util.lua | 28 +++++ lua/git/hunks.lua | 23 ++-- plugin/git.lua | 4 + test/git/blame_test.lua | 220 ++++++++++++++++++++++++++++++++ test/git/hunks_test.lua | 26 ++++ 7 files changed, 589 insertions(+), 65 deletions(-) create mode 100644 lua/git/core/popup.lua diff --git a/lua/git/blame.lua b/lua/git/blame.lua index 663af28..f15defb 100644 --- a/lua/git/blame.lua +++ b/lua/git/blame.lua @@ -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 ---@field line_sha table +---@field line_orig table +---@field line_file table ---@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 ---@field line_sha table +---@field line_orig table +---@field line_file table ---@field tick integer? ---@field epoch integer ---@field pending fun()[] @@ -72,20 +78,33 @@ local function parse_porcelain(stdout) local commits = {} ---@type table local line_sha = {} + ---@type table + local line_orig = {} + ---@type table + 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 ----@param line_sha table +---@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 diff --git a/lua/git/core/popup.lua b/lua/git/core/popup.lua new file mode 100644 index 0000000..bcd6098 --- /dev/null +++ b/lua/git/core/popup.lua @@ -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 diff --git a/lua/git/core/util.lua b/lua/git/core/util.lua index f29cfed..0c85d41 100644 --- a/lua/git/core/util.lua +++ b/lua/git/core/util.lua @@ -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() diff --git a/lua/git/hunks.lua b/lua/git/hunks.lua index 279b4ba..447a8e0 100644 --- a/lua/git/hunks.lua +++ b/lua/git/hunks.lua @@ -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 diff --git a/plugin/git.lua b/plugin/git.lua index 0ae9dd4..a86c49f 100644 --- a/plugin/git.lua +++ b/plugin/git.lua @@ -40,12 +40,16 @@ local DEFAULT_HIGHLIGHTS = { GitHunkAdded = "Added", GitHunkChanged = "Changed", GitHunkRemoved = "Removed", + GitHunkHeader = "Statement", + GitHunkAnnotation = "Title", GitHunkAddLine = "DiffAdd", GitHunkDeleteLine = "DiffDelete", GitBlameAuthor = "GitAuthor", GitBlameDate = "GitDate", GitBlameSha = "GitSha", + + GitPopupEnd = "NonText", } local STAGED_HUNK_HL = { GitHunkStagedAdded = "GitHunkAdded", diff --git a/test/git/blame_test.lua b/test/git/blame_test.lua index 598b8d5..e031f65 100644 --- a/test/git/blame_test.lua +++ b/test/git/blame_test.lua @@ -199,6 +199,226 @@ t.test("line popup shows the commit for the cursor line", function() t.truthy((lines[1] or ""):find("t", 1, true), "author shown") end) +t.test("line popup appends the commit's hunk for the cursor line", function() + local dir = h.make_repo({ ["a.txt"] = "alpha\nbeta\ngamma\n" }) + t.write(dir, "a.txt", "alpha\nBETA\ngamma\n") + h.git(dir, "add", "a.txt") + h.git(dir, "commit", "-q", "-m", "change beta") + vim.cmd.edit(dir .. "/a.txt") + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + blame.line_popup(buf) + local float = wait_float() + t.defer(function() + pcall(vim.api.nvim_win_close, float, true) + end) + t.wait_for(function() + local lines = vim.api.nvim_buf_get_lines( + vim.api.nvim_win_get_buf(float), + 0, + -1, + false + ) + for _, l in ipairs(lines) do + if l:match("^@@") then + return true + end + end + return false + end, "hunk header to appear in the blame popup") + local lines = vim.api.nvim_buf_get_lines( + vim.api.nvim_win_get_buf(float), + 0, + -1, + false + ) + local has = {} ---@type table + for _, l in ipairs(lines) do + has[l] = true + end + t.truthy(has["-beta"], "popup includes the pre-image of the changed line") + t.truthy(has["+BETA"], "popup includes the post-image of the changed line") + t.truthy( + has["Hunk 1 of 1"], + "popup annotates the hunk index even when the commit only has one" + ) + local pbuf = vim.api.nvim_win_get_buf(float) + local seen = {} ---@type table + for _, m in + ipairs(vim.api.nvim_buf_get_extmarks(pbuf, -1, 0, -1, { + details = true, + })) + do + local hl = m[4] and m[4].line_hl_group + if hl then + seen[hl] = true + end + end + t.truthy(seen["GitHunkAdded"], "+ line gets GitHunkAdded") + t.truthy(seen["GitHunkRemoved"], "- line gets GitHunkRemoved") + t.truthy(seen["GitHunkHeader"], "@@ header gets GitHunkHeader") + t.truthy( + seen["GitHunkAnnotation"], + "hunk-index annotation gets GitHunkAnnotation" + ) +end) + +t.test( + "line popup picks the hunk that contains the cursor, not the first one", + function() + local lines_initial = {} + local lines_changed = {} + for i = 1, 12 do + table.insert(lines_initial, "line" .. i) + table.insert(lines_changed, "line" .. i) + end + lines_changed[2] = "FIRST" + lines_changed[10] = "SECOND" + local initial = table.concat(lines_initial, "\n") .. "\n" + local changed = table.concat(lines_changed, "\n") .. "\n" + local dir = h.make_repo({ ["a.txt"] = initial }) + t.write(dir, "a.txt", changed) + h.git(dir, "add", "a.txt") + h.git(dir, "commit", "-q", "-m", "change line 2 and line 10") + vim.cmd.edit(dir .. "/a.txt") + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_win_set_cursor(0, { 10, 0 }) + blame.line_popup(buf) + local float = wait_float() + t.defer(function() + pcall(vim.api.nvim_win_close, float, true) + end) + t.wait_for(function() + for _, l in + ipairs( + vim.api.nvim_buf_get_lines( + vim.api.nvim_win_get_buf(float), + 0, + -1, + false + ) + ) + do + if l == "+SECOND" then + return true + end + end + return false + end, "popup to include the second hunk's added line") + local lines = vim.api.nvim_buf_get_lines( + vim.api.nvim_win_get_buf(float), + 0, + -1, + false + ) + local has = {} ---@type table + for _, l in ipairs(lines) do + has[l] = true + end + t.truthy(has["-line10"], "popup includes second hunk pre-image") + t.truthy(has["+SECOND"], "popup includes second hunk post-image") + t.falsy(has["-line2"], "popup omits the first hunk's pre-image") + t.falsy(has["+FIRST"], "popup omits the first hunk's post-image") + t.truthy( + has["Hunk 2 of 2"], + "popup annotates which hunk is selected and how many total" + ) + end +) + +t.test( + "line popup finds the diff via the historical filename after a rename", + function() + local dir = h.make_repo({ ["old.txt"] = "alpha\nbeta\n" }) + t.write(dir, "old.txt", "ALPHA\nbeta\n") + h.git(dir, "add", "old.txt") + h.git(dir, "commit", "-q", "-m", "change line 1") + h.git(dir, "mv", "old.txt", "new.txt") + h.git(dir, "commit", "-q", "-m", "rename") + vim.cmd.edit(dir .. "/new.txt") + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + blame.line_popup(buf) + local float = wait_float() + t.defer(function() + pcall(vim.api.nvim_win_close, float, true) + end) + t.wait_for(function() + for _, l in + ipairs( + vim.api.nvim_buf_get_lines( + vim.api.nvim_win_get_buf(float), + 0, + -1, + false + ) + ) + do + if l == "+ALPHA" then + return true + end + end + return false + end, "popup to include the pre-rename commit's diff via old.txt") + end +) + +t.test("line popup collapses a new-file diff to a single marker", function() + local dir = h.make_repo({ ["a.txt"] = "alpha\nbeta\ngamma\n" }) + vim.cmd.edit(dir .. "/a.txt") + local buf = vim.api.nvim_get_current_buf() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + blame.line_popup(buf) + local float = wait_float() + t.defer(function() + pcall(vim.api.nvim_win_close, float, true) + end) + t.wait_for(function() + for _, l in + ipairs( + vim.api.nvim_buf_get_lines( + vim.api.nvim_win_get_buf(float), + 0, + -1, + false + ) + ) + do + if l == "(new file)" then + return true + end + end + return false + end, "popup to show the new-file marker") + local lines = vim.api.nvim_buf_get_lines( + vim.api.nvim_win_get_buf(float), + 0, + -1, + false + ) + for _, l in ipairs(lines) do + t.falsy( + vim.startswith(l, "+alpha") + or vim.startswith(l, "+beta") + or vim.startswith(l, "+gamma"), + "popup does not dump the file content as + lines" + ) + end + local pbuf = vim.api.nvim_win_get_buf(float) + local seen = {} ---@type table + for _, m in + ipairs(vim.api.nvim_buf_get_extmarks(pbuf, -1, 0, -1, { + details = true, + })) + do + local hl = m[4] and m[4].line_hl_group + if hl then + seen[hl] = true + end + end + t.truthy(seen["GitHunkAdded"], "new-file marker gets GitHunkAdded") +end) + t.test("re-invoking the line popup focuses the open float", function() local _, buf = setup("alpha\nbeta\n") vim.api.nvim_set_current_buf(buf) diff --git a/test/git/hunks_test.lua b/test/git/hunks_test.lua index bfb17f9..ee9d35f 100644 --- a/test/git/hunks_test.lua +++ b/test/git/hunks_test.lua @@ -608,6 +608,32 @@ t.test("preview_hunk shows the hunk body without file headers", function() end end) +t.test("preview_hunk highlights diff lines via per-line extmarks", function() + local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") + vim.api.nvim_set_current_buf(buf) + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + hunks.preview_hunk(buf) + local float = assert(find_float(), "preview float should open") + t.defer(function() + pcall(vim.api.nvim_win_close, float, true) + end) + local pbuf = vim.api.nvim_win_get_buf(float) + local seen = {} ---@type table + for _, m in + ipairs(vim.api.nvim_buf_get_extmarks(pbuf, -1, 0, -1, { + details = true, + })) + do + local hl = m[4] and m[4].line_hl_group + if hl then + seen[hl] = true + end + end + t.truthy(seen["GitHunkHeader"], "@@ header gets GitHunkHeader") + t.truthy(seen["GitHunkAdded"], "+ line gets GitHunkAdded") + t.truthy(seen["GitHunkRemoved"], "- line gets GitHunkRemoved") +end) + t.test("preview_hunk re-invocation focuses the open float", function() local _, buf = setup("a\nb\nc\n", "a\nB\nc\n") vim.api.nvim_set_current_buf(buf)