feat(gitsigns): highlight blame popup with treesitter and intra-line diffs
Problem: gitsigns blame popups show flat `GitSignsAddPreview`/ `GitSignsDeletePreview` highlights with no treesitter syntax or character-level intra-line diffs. Solution: add `lua/diffs/gitsigns.lua` which patches `Popup.create` and `Popup.update` to intercept blame popups, parse hunk sections, clear gitsigns' own highlights, and apply `highlight_hunk` with manual `@diff.plus`/`@diff.minus` prefix extmarks. Wired in `plugin/diffs.lua` with `User GitAttach` lazy-load retry.
This commit is contained in:
parent
c64ca827f3
commit
09a0418942
3 changed files with 432 additions and 0 deletions
183
lua/diffs/gitsigns.lua
Normal file
183
lua/diffs/gitsigns.lua
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
local M = {}
|
||||
|
||||
local api = vim.api
|
||||
local fn = vim.fn
|
||||
local dbg = require('diffs.log').dbg
|
||||
|
||||
local ns = api.nvim_create_namespace('diffs-gitsigns')
|
||||
local gs_popup_ns = api.nvim_create_namespace('gitsigns_popup')
|
||||
|
||||
local patched = false
|
||||
|
||||
---@param ft string
|
||||
---@return string?
|
||||
local function get_lang(ft)
|
||||
local lang = vim.treesitter.language.get_lang(ft)
|
||||
if not lang then
|
||||
return nil
|
||||
end
|
||||
local ok = pcall(vim.treesitter.language.inspect, lang)
|
||||
return ok and lang or nil
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param src_filename string
|
||||
---@param src_ft string?
|
||||
---@param src_lang string?
|
||||
---@return diffs.Hunk[]
|
||||
function M.parse_blame_hunks(bufnr, src_filename, src_ft, src_lang)
|
||||
local lines = api.nvim_buf_get_lines(bufnr, 0, -1, false)
|
||||
local hunks = {}
|
||||
local hunk_lines = {}
|
||||
local hunk_start = nil
|
||||
|
||||
for i, line in ipairs(lines) do
|
||||
if line:match('^Hunk %d+ of %d+') then
|
||||
if hunk_start and #hunk_lines > 0 then
|
||||
table.insert(hunks, {
|
||||
filename = src_filename,
|
||||
ft = src_ft,
|
||||
lang = src_lang,
|
||||
start_line = hunk_start,
|
||||
prefix_width = 1,
|
||||
quote_width = 0,
|
||||
lines = hunk_lines,
|
||||
})
|
||||
end
|
||||
hunk_lines = {}
|
||||
hunk_start = i
|
||||
elseif hunk_start then
|
||||
if line:match('^%(guessed:') then
|
||||
hunk_start = i
|
||||
else
|
||||
local prefix = line:sub(1, 1)
|
||||
if prefix == ' ' or prefix == '+' or prefix == '-' then
|
||||
if #hunk_lines == 0 then
|
||||
hunk_start = i - 1
|
||||
end
|
||||
table.insert(hunk_lines, line)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if hunk_start and #hunk_lines > 0 then
|
||||
table.insert(hunks, {
|
||||
filename = src_filename,
|
||||
ft = src_ft,
|
||||
lang = src_lang,
|
||||
start_line = hunk_start,
|
||||
prefix_width = 1,
|
||||
quote_width = 0,
|
||||
lines = hunk_lines,
|
||||
})
|
||||
end
|
||||
|
||||
return hunks
|
||||
end
|
||||
|
||||
---@param preview_winid integer
|
||||
---@param preview_bufnr integer
|
||||
local function on_preview(preview_winid, preview_bufnr)
|
||||
local ok, err = pcall(function()
|
||||
if not api.nvim_buf_is_valid(preview_bufnr) then
|
||||
return
|
||||
end
|
||||
if not api.nvim_win_is_valid(preview_winid) then
|
||||
return
|
||||
end
|
||||
|
||||
local win = api.nvim_get_current_win()
|
||||
if win == preview_winid then
|
||||
win = fn.win_getid(fn.winnr('#'))
|
||||
end
|
||||
if win == -1 or win == 0 or not api.nvim_win_is_valid(win) then
|
||||
return
|
||||
end
|
||||
|
||||
local srcbuf = api.nvim_win_get_buf(win)
|
||||
if not api.nvim_buf_is_loaded(srcbuf) then
|
||||
return
|
||||
end
|
||||
|
||||
local ft = vim.bo[srcbuf].filetype
|
||||
local name = api.nvim_buf_get_name(srcbuf)
|
||||
if not name or name == '' then
|
||||
name = ft and ('a.' .. ft) or 'unknown'
|
||||
end
|
||||
local lang = ft and get_lang(ft) or nil
|
||||
|
||||
local hunks = M.parse_blame_hunks(preview_bufnr, name, ft, lang)
|
||||
if #hunks == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local diff_start = hunks[1].start_line
|
||||
local last = hunks[#hunks]
|
||||
local diff_end = last.start_line + #last.lines
|
||||
|
||||
api.nvim_buf_clear_namespace(preview_bufnr, gs_popup_ns, diff_start, diff_end)
|
||||
api.nvim_buf_clear_namespace(preview_bufnr, ns, diff_start, diff_end)
|
||||
|
||||
local opts = require('diffs').get_highlight_opts()
|
||||
local highlight = require('diffs.highlight')
|
||||
for _, hunk in ipairs(hunks) do
|
||||
highlight.highlight_hunk(preview_bufnr, ns, hunk, opts)
|
||||
for j, line in ipairs(hunk.lines) do
|
||||
local ch = line:sub(1, 1)
|
||||
if ch == '+' or ch == '-' then
|
||||
pcall(api.nvim_buf_set_extmark, preview_bufnr, ns, hunk.start_line + j - 1, 0, {
|
||||
end_col = 1,
|
||||
hl_group = ch == '+' and '@diff.plus' or '@diff.minus',
|
||||
priority = opts.highlights.priorities.syntax,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
dbg('gitsigns blame: highlighted %d hunks in popup buf %d', #hunks, preview_bufnr)
|
||||
end)
|
||||
if not ok then
|
||||
dbg('gitsigns blame error: %s', err)
|
||||
end
|
||||
end
|
||||
|
||||
---@return boolean
|
||||
function M.setup()
|
||||
if patched then
|
||||
return true
|
||||
end
|
||||
|
||||
local pop_ok, Popup = pcall(require, 'gitsigns.popup')
|
||||
if not pop_ok or not Popup then
|
||||
return false
|
||||
end
|
||||
|
||||
Popup.create = (function(orig)
|
||||
return function(...)
|
||||
local winid, bufnr = orig(...)
|
||||
on_preview(winid, bufnr)
|
||||
return winid, bufnr
|
||||
end
|
||||
end)(Popup.create)
|
||||
|
||||
Popup.update = (function(orig)
|
||||
return function(winid, bufnr, ...)
|
||||
orig(winid, bufnr, ...)
|
||||
on_preview(winid, bufnr)
|
||||
end
|
||||
end)(Popup.update)
|
||||
|
||||
patched = true
|
||||
dbg('gitsigns popup patched')
|
||||
return true
|
||||
end
|
||||
|
||||
M._test = {
|
||||
parse_blame_hunks = M.parse_blame_hunks,
|
||||
on_preview = on_preview,
|
||||
ns = ns,
|
||||
gs_popup_ns = gs_popup_ns,
|
||||
}
|
||||
|
||||
return M
|
||||
|
|
@ -5,6 +5,19 @@ vim.g.loaded_diffs = 1
|
|||
|
||||
require('diffs.commands').setup()
|
||||
|
||||
local gs_cfg = (vim.g.diffs or {}).gitsigns
|
||||
if gs_cfg == true or type(gs_cfg) == 'table' then
|
||||
if not require('diffs.gitsigns').setup() then
|
||||
vim.api.nvim_create_autocmd('User', {
|
||||
pattern = 'GitAttach',
|
||||
once = true,
|
||||
callback = function()
|
||||
require('diffs.gitsigns').setup()
|
||||
end,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
vim.api.nvim_create_autocmd('FileType', {
|
||||
pattern = require('diffs').compute_filetypes(vim.g.diffs or {}),
|
||||
callback = function(args)
|
||||
|
|
|
|||
236
spec/gitsigns_spec.lua
Normal file
236
spec/gitsigns_spec.lua
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
require('spec.helpers')
|
||||
local gs = require('diffs.gitsigns')
|
||||
|
||||
local function setup_highlight_groups()
|
||||
local normal = vim.api.nvim_get_hl(0, { name = 'Normal' })
|
||||
local diff_add = vim.api.nvim_get_hl(0, { name = 'DiffAdd' })
|
||||
local diff_delete = vim.api.nvim_get_hl(0, { name = 'DiffDelete' })
|
||||
vim.api.nvim_set_hl(0, 'DiffsClear', { fg = normal.fg or 0xc0c0c0 })
|
||||
vim.api.nvim_set_hl(0, 'DiffsAdd', { bg = diff_add.bg or 0x2e4a3a })
|
||||
vim.api.nvim_set_hl(0, 'DiffsDelete', { bg = diff_delete.bg or 0x4a2e3a })
|
||||
vim.api.nvim_set_hl(0, 'DiffsAddText', { bg = 0x00FF00 })
|
||||
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { bg = 0xFF0000 })
|
||||
end
|
||||
|
||||
local function create_buffer(lines)
|
||||
local bufnr = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines or {})
|
||||
return bufnr
|
||||
end
|
||||
|
||||
local function delete_buffer(bufnr)
|
||||
if bufnr and vim.api.nvim_buf_is_valid(bufnr) then
|
||||
vim.api.nvim_buf_delete(bufnr, { force = true })
|
||||
end
|
||||
end
|
||||
|
||||
describe('gitsigns', function()
|
||||
describe('parse_blame_hunks', function()
|
||||
it('parses a single hunk', function()
|
||||
local bufnr = create_buffer({
|
||||
'commit abc1234',
|
||||
'Author: Test User',
|
||||
'',
|
||||
'Hunk 1 of 1',
|
||||
' local x = 1',
|
||||
'-local y = 2',
|
||||
'+local y = 3',
|
||||
' local z = 4',
|
||||
})
|
||||
local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua')
|
||||
assert.are.equal(1, #hunks)
|
||||
assert.are.equal('test.lua', hunks[1].filename)
|
||||
assert.are.equal('lua', hunks[1].ft)
|
||||
assert.are.equal('lua', hunks[1].lang)
|
||||
assert.are.equal(1, hunks[1].prefix_width)
|
||||
assert.are.equal(0, hunks[1].quote_width)
|
||||
assert.are.equal(4, #hunks[1].lines)
|
||||
assert.are.equal(4, hunks[1].start_line)
|
||||
assert.are.equal(' local x = 1', hunks[1].lines[1])
|
||||
assert.are.equal('-local y = 2', hunks[1].lines[2])
|
||||
assert.are.equal('+local y = 3', hunks[1].lines[3])
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('parses multiple hunks', function()
|
||||
local bufnr = create_buffer({
|
||||
'commit abc1234',
|
||||
'',
|
||||
'Hunk 1 of 2',
|
||||
'-local a = 1',
|
||||
'+local a = 2',
|
||||
'Hunk 2 of 2',
|
||||
' local b = 3',
|
||||
'+local c = 4',
|
||||
})
|
||||
local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua')
|
||||
assert.are.equal(2, #hunks)
|
||||
assert.are.equal(2, #hunks[1].lines)
|
||||
assert.are.equal(2, #hunks[2].lines)
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('skips guessed-offset lines', function()
|
||||
local bufnr = create_buffer({
|
||||
'commit abc1234',
|
||||
'',
|
||||
'Hunk 1 of 1',
|
||||
'(guessed: hunk offset may be wrong)',
|
||||
' local x = 1',
|
||||
'+local y = 2',
|
||||
})
|
||||
local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua')
|
||||
assert.are.equal(1, #hunks)
|
||||
assert.are.equal(2, #hunks[1].lines)
|
||||
assert.are.equal(' local x = 1', hunks[1].lines[1])
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('returns empty table when no hunks present', function()
|
||||
local bufnr = create_buffer({
|
||||
'commit abc1234',
|
||||
'Author: Test User',
|
||||
'Date: 2024-01-01',
|
||||
})
|
||||
local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua')
|
||||
assert.are.equal(0, #hunks)
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('handles hunk with no diff lines after header', function()
|
||||
local bufnr = create_buffer({
|
||||
'Hunk 1 of 1',
|
||||
'some non-diff text',
|
||||
})
|
||||
local hunks = gs.parse_blame_hunks(bufnr, 'test.lua', 'lua', 'lua')
|
||||
assert.are.equal(0, #hunks)
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('on_preview', function()
|
||||
before_each(function()
|
||||
setup_highlight_groups()
|
||||
end)
|
||||
|
||||
it('applies extmarks to popup buffer with diff content', function()
|
||||
local bufnr = create_buffer({
|
||||
'commit abc1234',
|
||||
'',
|
||||
'Hunk 1 of 1',
|
||||
' local x = 1',
|
||||
'-local y = 2',
|
||||
'+local y = 3',
|
||||
})
|
||||
|
||||
local winid = vim.api.nvim_open_win(bufnr, false, {
|
||||
relative = 'editor',
|
||||
width = 40,
|
||||
height = 10,
|
||||
row = 0,
|
||||
col = 0,
|
||||
})
|
||||
|
||||
gs._test.on_preview(winid, bufnr)
|
||||
|
||||
local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, gs._test.ns, 0, -1, { details = true })
|
||||
assert.is_true(#extmarks > 0)
|
||||
|
||||
vim.api.nvim_win_close(winid, true)
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('clears gitsigns_popup namespace on diff region', function()
|
||||
local bufnr = create_buffer({
|
||||
'commit abc1234',
|
||||
'',
|
||||
'Hunk 1 of 1',
|
||||
' local x = 1',
|
||||
'+local y = 2',
|
||||
})
|
||||
|
||||
vim.api.nvim_buf_set_extmark(bufnr, gs._test.gs_popup_ns, 3, 0, {
|
||||
end_col = 12,
|
||||
hl_group = 'GitSignsAddPreview',
|
||||
})
|
||||
vim.api.nvim_buf_set_extmark(bufnr, gs._test.gs_popup_ns, 4, 0, {
|
||||
end_col = 12,
|
||||
hl_group = 'GitSignsAddPreview',
|
||||
})
|
||||
|
||||
local winid = vim.api.nvim_open_win(bufnr, false, {
|
||||
relative = 'editor',
|
||||
width = 40,
|
||||
height = 10,
|
||||
row = 0,
|
||||
col = 0,
|
||||
})
|
||||
|
||||
gs._test.on_preview(winid, bufnr)
|
||||
|
||||
local gs_extmarks =
|
||||
vim.api.nvim_buf_get_extmarks(bufnr, gs._test.gs_popup_ns, 0, -1, { details = true })
|
||||
assert.are.equal(0, #gs_extmarks)
|
||||
|
||||
vim.api.nvim_win_close(winid, true)
|
||||
delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('does not error on invalid buffer', function()
|
||||
assert.has_no.errors(function()
|
||||
gs._test.on_preview(0, 99999)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe('setup', function()
|
||||
it('returns false when gitsigns.popup is not available', function()
|
||||
local saved = package.loaded['gitsigns.popup']
|
||||
package.loaded['gitsigns.popup'] = nil
|
||||
package.preload['gitsigns.popup'] = nil
|
||||
|
||||
local fresh = loadfile('lua/diffs/gitsigns.lua')()
|
||||
local result = fresh.setup()
|
||||
assert.is_false(result)
|
||||
|
||||
package.loaded['gitsigns.popup'] = saved
|
||||
end)
|
||||
|
||||
it('patches gitsigns.popup when available', function()
|
||||
local create_called = false
|
||||
local update_called = false
|
||||
local mock_popup = {
|
||||
create = function()
|
||||
create_called = true
|
||||
local bufnr = create_buffer({ 'test' })
|
||||
local winid = vim.api.nvim_open_win(bufnr, false, {
|
||||
relative = 'editor',
|
||||
width = 10,
|
||||
height = 1,
|
||||
row = 0,
|
||||
col = 0,
|
||||
})
|
||||
return winid, bufnr
|
||||
end,
|
||||
update = function()
|
||||
update_called = true
|
||||
end,
|
||||
}
|
||||
|
||||
local saved = package.loaded['gitsigns.popup']
|
||||
package.loaded['gitsigns.popup'] = mock_popup
|
||||
|
||||
local fresh = loadfile('lua/diffs/gitsigns.lua')()
|
||||
local result = fresh.setup()
|
||||
assert.is_true(result)
|
||||
|
||||
mock_popup.create()
|
||||
assert.is_true(create_called)
|
||||
|
||||
mock_popup.update(0, 0)
|
||||
assert.is_true(update_called)
|
||||
|
||||
package.loaded['gitsigns.popup'] = saved
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
Loading…
Add table
Add a link
Reference in a new issue