From 09a0418942f1d16848134592808fba7f4ffe8733 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 08:35:26 -0500 Subject: [PATCH] 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. --- lua/diffs/gitsigns.lua | 183 ++++++++++++++++++++++++++++++++ plugin/diffs.lua | 13 +++ spec/gitsigns_spec.lua | 236 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 432 insertions(+) create mode 100644 lua/diffs/gitsigns.lua create mode 100644 spec/gitsigns_spec.lua diff --git a/lua/diffs/gitsigns.lua b/lua/diffs/gitsigns.lua new file mode 100644 index 0000000..da86106 --- /dev/null +++ b/lua/diffs/gitsigns.lua @@ -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 diff --git a/plugin/diffs.lua b/plugin/diffs.lua index e572cf5..340df9c 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -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) diff --git a/spec/gitsigns_spec.lua b/spec/gitsigns_spec.lua new file mode 100644 index 0000000..b2b6e2a --- /dev/null +++ b/spec/gitsigns_spec.lua @@ -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)