diff --git a/README.md b/README.md index 2a7bd73..0051f21 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ with language-aware syntax highlighting. word-level accuracy) - `:Gdiff` unified diff against any revision - Inline merge conflict detection, highlighting, and resolution +- gitsigns.nvim blame popup highlighting - Email quoting/patch syntax support (`> diff ...`) - Vim syntax fallback - Configurable highlighiting blend & priorities @@ -58,14 +59,15 @@ luarocks install diffs.nvim Do not lazy load `diffs.nvim` with `event`, `lazy`, `ft`, `config`, or `keys` to control loading - `diffs.nvim` lazy-loads itself. -**Q: Does diffs.nvim support vim-fugitive/Neogit?** +**Q: Does diffs.nvim support vim-fugitive/Neogit/gitsigns?** -Yes. Enable it in your config: +Yes. Enable integrations in your config: ```lua vim.g.diffs = { fugitive = true, neogit = true, + gitsigns = true, } ``` @@ -119,4 +121,5 @@ See the documentation for more information. - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - [@phanen](https://github.com/phanen) - diff header highlighting, unknown filetype fix, shebang/modeline detection, treesitter injection support, - decoration provider highlighting architecture + decoration provider highlighting architecture, gitsigns blame popup + highlighting diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 6288f0c..f0205d5 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -15,6 +15,7 @@ Features: ~ - Diff header highlighting (`diff --git`, `index`, `---`, `+++`) - Syntax highlighting in |:Gdiffsplit| / |:Gvdiffsplit| side-by-side diffs - |:Gdiff| command for unified diff against any git revision +- gitsigns.nvim blame popup highlighting (see |diffs-gitsigns|) - Background-only diff colors for any `&diff` buffer (vimdiff, diffthis, etc.) - Vim syntax fallback for languages without a treesitter parser - Blended diff background colors that preserve syntax visibility @@ -35,12 +36,13 @@ CONTENTS *diffs-contents* 8. Conflict Resolution .................................... |diffs-conflict| 9. Merge Diff Resolution ..................................... |diffs-merge| 10. Neogit ................................................... |diffs-neogit| - 11. API ......................................................... |diffs-api| - 12. Implementation ................................... |diffs-implementation| - 13. Known Limitations ................................... |diffs-limitations| - 14. Highlight Groups ..................................... |diffs-highlights| - 15. Health Check ............................................. |diffs-health| - 16. Acknowledgements ............................... |diffs-acknowledgements| + 11. Gitsigns ................................................ |diffs-gitsigns| + 12. API ......................................................... |diffs-api| + 13. Implementation ................................... |diffs-implementation| + 14. Known Limitations ................................... |diffs-limitations| + 15. Highlight Groups ..................................... |diffs-highlights| + 16. Health Check ............................................. |diffs-health| + 17. Acknowledgements ............................... |diffs-acknowledgements| ============================================================================== REQUIREMENTS *diffs-requirements* @@ -78,6 +80,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: hide_prefix = false, fugitive = false, neogit = false, + gitsigns = false, extra_filetypes = {}, highlights = { background = true, @@ -161,6 +164,16 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads: vim.g.diffs = { neogit = true } < + {gitsigns} (boolean|table, default: false) + Enable gitsigns.nvim blame popup highlighting. + Pass `true` or `{}` to enable, `false` to + disable. When active, `:Gitsigns blame_line` + popups receive treesitter syntax, line + backgrounds, and intra-line character diffs. + See |diffs-gitsigns|. >lua + vim.g.diffs = { gitsigns = true } +< + {extra_filetypes} (table, default: {}) Additional filetypes to attach to, beyond the built-in `git`, `gitcommit`, and any enabled @@ -650,6 +663,31 @@ line visuals. The overrides are reapplied on `ColorScheme` since Neogit re-defines its groups then. When `neogit = false`, no highlight overrides are applied. +============================================================================== +GITSIGNS *diffs-gitsigns* + +diffs.nvim can enhance gitsigns.nvim blame popups with syntax highlighting. +Enable gitsigns support in your config: >lua + vim.g.diffs = { gitsigns = true } +< + +When `:Gitsigns blame_line full=true` opens a popup, diffs.nvim intercepts +the popup and replaces gitsigns' flat `GitSignsAddPreview`/ +`GitSignsDeletePreview` highlights with: + +- Treesitter syntax highlighting on the code content +- `DiffsAdd`/`DiffsDelete` line backgrounds +- Character-level intra-line diffs (`DiffsAddText`/`DiffsDeleteText`) +- `@diff.plus`/`@diff.minus` coloring on `+`/`-` prefix characters + +The integration patches `gitsigns.popup.create` and `gitsigns.popup.update` +so highlights persist across gitsigns' two-phase render (initial popup, then +update with GitHub/PR data). If gitsigns loads after diffs.nvim, a +`User GitAttach` autocmd retries the setup automatically. + +Highlights are applied in a separate `diffs-gitsigns` namespace and do not +interfere with the main decoration provider used for diff buffers. + ============================================================================== API *diffs-api* @@ -878,7 +916,8 @@ ACKNOWLEDGEMENTS *diffs-acknowledgements* - codediff.nvim (https://github.com/esmuellert/codediff.nvim) - diffview.nvim (https://github.com/sindrets/diffview.nvim) - @phanen (https://github.com/phanen) - diff header highlighting, - treesitter injection support + treesitter injection support, blame_hl.nvim (gitsigns blame popup + highlighting inspiration) ============================================================================== vim:tw=78:ts=8:ft=help:norl: diff --git a/lua/diffs/gitsigns.lua b/lua/diffs/gitsigns.lua new file mode 100644 index 0000000..0439fb2 --- /dev/null +++ b/lua/diffs/gitsigns.lua @@ -0,0 +1,172 @@ +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 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 require('diffs.parser').get_lang_from_ft(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/lua/diffs/init.lua b/lua/diffs/init.lua index 7e5dd04..06b6ad9 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -38,6 +38,8 @@ ---@class diffs.NeogitConfig +---@class diffs.GitsignsConfig + ---@class diffs.ConflictKeymaps ---@field ours string|false ---@field theirs string|false @@ -62,6 +64,7 @@ ---@field highlights diffs.Highlights ---@field fugitive diffs.FugitiveConfig|false ---@field neogit diffs.NeogitConfig|false +---@field gitsigns diffs.GitsignsConfig|false ---@field conflict diffs.ConflictConfig ---@class diffs @@ -141,6 +144,7 @@ local default_config = { }, fugitive = false, neogit = false, + gitsigns = false, conflict = { enabled = true, disable_diagnostics = true, @@ -591,6 +595,10 @@ local function init() opts.neogit = {} end + if opts.gitsigns == true then + opts.gitsigns = {} + end + vim.validate('debug', opts.debug, function(v) return v == nil or type(v) == 'boolean' or type(v) == 'string' end, 'boolean or string (file path)') @@ -601,6 +609,9 @@ local function init() vim.validate('neogit', opts.neogit, function(v) return v == nil or v == false or type(v) == 'table' end, 'table or false') + vim.validate('gitsigns', opts.gitsigns, function(v) + return v == nil or v == false or type(v) == 'table' + end, 'table or false') vim.validate('extra_filetypes', opts.extra_filetypes, 'table', true) vim.validate('highlights', opts.highlights, 'table', true) @@ -990,6 +1001,12 @@ function M.get_conflict_config() return config.conflict end +---@return diffs.HunkOpts +function M.get_highlight_opts() + init() + return { hide_prefix = config.hide_prefix, highlights = config.highlights } +end + local function process_pending_clear(bufnr) local entry = hunk_cache[bufnr] if entry and entry.pending_clear then diff --git a/lua/diffs/parser.lua b/lua/diffs/parser.lua index ccbd46a..aa63daf 100644 --- a/lua/diffs/parser.lua +++ b/lua/diffs/parser.lua @@ -353,6 +353,8 @@ function M.parse_buffer(bufnr) return hunks end +M.get_lang_from_ft = get_lang_from_ft + M._test = { ft_lang_cache = ft_lang_cache, } 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)