Problem: get_repo_root() shells out on every call, causing 4-6 redundant subprocesses per gdiff_file() invocation. highlight_vim_syntax() leaks a scratch buffer if nvim_buf_call errors. lib.ensure() silently drops callbacks during download, permanently missing intra-line highlights. The debounce timer callback can operate on an invalid buffer. Solution: Cache get_repo_root() results by parent directory. Wrap nvim_buf_call and nvim_buf_delete in pcall so the scratch buffer is always cleaned up. Queue pending callbacks in lib.ensure() so all callers receive the handle once the download completes. Guard the debounce timer callback with nvim_buf_is_valid.
582 lines
16 KiB
Lua
582 lines
16 KiB
Lua
---@class diffs.TreesitterConfig
|
|
---@field enabled boolean
|
|
---@field max_lines integer
|
|
|
|
---@class diffs.VimConfig
|
|
---@field enabled boolean
|
|
---@field max_lines integer
|
|
|
|
---@class diffs.IntraConfig
|
|
---@field enabled boolean
|
|
---@field algorithm string
|
|
---@field max_lines integer
|
|
|
|
---@class diffs.ContextConfig
|
|
---@field enabled boolean
|
|
---@field lines integer
|
|
|
|
---@class diffs.Highlights
|
|
---@field background boolean
|
|
---@field gutter boolean
|
|
---@field blend_alpha? number
|
|
---@field overrides? table<string, table>
|
|
---@field context diffs.ContextConfig
|
|
---@field treesitter diffs.TreesitterConfig
|
|
---@field vim diffs.VimConfig
|
|
---@field intra diffs.IntraConfig
|
|
|
|
---@class diffs.FugitiveConfig
|
|
---@field horizontal string|false
|
|
---@field vertical string|false
|
|
|
|
---@class diffs.ConflictKeymaps
|
|
---@field ours string|false
|
|
---@field theirs string|false
|
|
---@field both string|false
|
|
---@field none string|false
|
|
---@field next string|false
|
|
---@field prev string|false
|
|
|
|
---@class diffs.ConflictConfig
|
|
---@field enabled boolean
|
|
---@field disable_diagnostics boolean
|
|
---@field show_virtual_text boolean
|
|
---@field keymaps diffs.ConflictKeymaps
|
|
|
|
---@class diffs.Config
|
|
---@field debug boolean
|
|
---@field debounce_ms integer
|
|
---@field hide_prefix boolean
|
|
---@field highlights diffs.Highlights
|
|
---@field fugitive diffs.FugitiveConfig
|
|
---@field conflict diffs.ConflictConfig
|
|
|
|
---@class diffs
|
|
---@field attach fun(bufnr?: integer)
|
|
---@field refresh fun(bufnr?: integer)
|
|
local M = {}
|
|
|
|
local highlight = require('diffs.highlight')
|
|
local log = require('diffs.log')
|
|
local parser = require('diffs.parser')
|
|
|
|
local ns = vim.api.nvim_create_namespace('diffs')
|
|
|
|
---@param hex integer
|
|
---@param bg_hex integer
|
|
---@param alpha number
|
|
---@return integer
|
|
local function blend_color(hex, bg_hex, alpha)
|
|
---@diagnostic disable: undefined-global
|
|
local r = bit.band(bit.rshift(hex, 16), 0xFF)
|
|
local g = bit.band(bit.rshift(hex, 8), 0xFF)
|
|
local b = bit.band(hex, 0xFF)
|
|
|
|
local bg_r = bit.band(bit.rshift(bg_hex, 16), 0xFF)
|
|
local bg_g = bit.band(bit.rshift(bg_hex, 8), 0xFF)
|
|
local bg_b = bit.band(bg_hex, 0xFF)
|
|
|
|
local blend_r = math.floor(r * alpha + bg_r * (1 - alpha))
|
|
local blend_g = math.floor(g * alpha + bg_g * (1 - alpha))
|
|
local blend_b = math.floor(b * alpha + bg_b * (1 - alpha))
|
|
|
|
return bit.bor(bit.lshift(blend_r, 16), bit.lshift(blend_g, 8), blend_b)
|
|
---@diagnostic enable: undefined-global
|
|
end
|
|
|
|
---@param name string
|
|
---@return table
|
|
local function resolve_hl(name)
|
|
local hl = vim.api.nvim_get_hl(0, { name = name })
|
|
while hl.link do
|
|
hl = vim.api.nvim_get_hl(0, { name = hl.link })
|
|
end
|
|
return hl
|
|
end
|
|
|
|
---@type diffs.Config
|
|
local default_config = {
|
|
debug = false,
|
|
debounce_ms = 0,
|
|
hide_prefix = false,
|
|
highlights = {
|
|
background = true,
|
|
gutter = true,
|
|
context = {
|
|
enabled = true,
|
|
lines = 25,
|
|
},
|
|
treesitter = {
|
|
enabled = true,
|
|
max_lines = 500,
|
|
},
|
|
vim = {
|
|
enabled = false,
|
|
max_lines = 200,
|
|
},
|
|
intra = {
|
|
enabled = true,
|
|
algorithm = 'default',
|
|
max_lines = 500,
|
|
},
|
|
},
|
|
fugitive = {
|
|
horizontal = 'du',
|
|
vertical = 'dU',
|
|
},
|
|
conflict = {
|
|
enabled = true,
|
|
disable_diagnostics = true,
|
|
show_virtual_text = true,
|
|
keymaps = {
|
|
ours = 'doo',
|
|
theirs = 'dot',
|
|
both = 'dob',
|
|
none = 'don',
|
|
next = ']x',
|
|
prev = '[x',
|
|
},
|
|
},
|
|
}
|
|
|
|
---@type diffs.Config
|
|
local config = vim.deepcopy(default_config)
|
|
|
|
local initialized = false
|
|
|
|
---@type table<integer, boolean>
|
|
local attached_buffers = {}
|
|
|
|
---@type table<integer, boolean>
|
|
local diff_windows = {}
|
|
|
|
---@param bufnr integer
|
|
---@return boolean
|
|
function M.is_fugitive_buffer(bufnr)
|
|
return vim.api.nvim_buf_get_name(bufnr):match('^fugitive://') ~= nil
|
|
end
|
|
|
|
local dbg = log.dbg
|
|
|
|
---@param bufnr integer
|
|
local function highlight_buffer(bufnr)
|
|
if not vim.api.nvim_buf_is_valid(bufnr) then
|
|
return
|
|
end
|
|
|
|
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
|
|
|
|
local hunks = parser.parse_buffer(bufnr)
|
|
dbg('found %d hunks in buffer %d', #hunks, bufnr)
|
|
for _, hunk in ipairs(hunks) do
|
|
highlight.highlight_hunk(bufnr, ns, hunk, {
|
|
hide_prefix = config.hide_prefix,
|
|
highlights = config.highlights,
|
|
})
|
|
end
|
|
end
|
|
|
|
---@param bufnr integer
|
|
---@return fun()
|
|
local function create_debounced_highlight(bufnr)
|
|
local timer = nil ---@type table?
|
|
return function()
|
|
if timer then
|
|
timer:stop() ---@diagnostic disable-line: undefined-field
|
|
timer:close() ---@diagnostic disable-line: undefined-field
|
|
timer = nil
|
|
end
|
|
local t = vim.uv.new_timer()
|
|
if not t then
|
|
highlight_buffer(bufnr)
|
|
return
|
|
end
|
|
timer = t
|
|
t:start(
|
|
config.debounce_ms,
|
|
0,
|
|
vim.schedule_wrap(function()
|
|
if timer == t then
|
|
timer = nil
|
|
t:close()
|
|
end
|
|
if vim.api.nvim_buf_is_valid(bufnr) then
|
|
highlight_buffer(bufnr)
|
|
end
|
|
end)
|
|
)
|
|
end
|
|
end
|
|
|
|
local function compute_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' })
|
|
local diff_added = resolve_hl('diffAdded')
|
|
local diff_removed = resolve_hl('diffRemoved')
|
|
|
|
local bg = normal.bg or 0x1e1e2e
|
|
local add_bg = diff_add.bg or 0x2e4a3a
|
|
local del_bg = diff_delete.bg or 0x4a2e3a
|
|
local add_fg = diff_added.fg or diff_add.fg or 0x80c080
|
|
local del_fg = diff_removed.fg or diff_delete.fg or 0xc08080
|
|
|
|
local blended_add = blend_color(add_bg, bg, 0.4)
|
|
local blended_del = blend_color(del_bg, bg, 0.4)
|
|
|
|
local alpha = config.highlights.blend_alpha or 0.6
|
|
local blended_add_text = blend_color(add_fg, bg, alpha)
|
|
local blended_del_text = blend_color(del_fg, bg, alpha)
|
|
|
|
vim.api.nvim_set_hl(0, 'DiffsClear', { default = true, fg = normal.fg or 0xc0c0c0 })
|
|
vim.api.nvim_set_hl(0, 'DiffsAdd', { default = true, bg = blended_add })
|
|
vim.api.nvim_set_hl(0, 'DiffsDelete', { default = true, bg = blended_del })
|
|
vim.api.nvim_set_hl(0, 'DiffsAddNr', { default = true, fg = blended_add_text, bg = blended_add })
|
|
vim.api.nvim_set_hl(
|
|
0,
|
|
'DiffsDeleteNr',
|
|
{ default = true, fg = blended_del_text, bg = blended_del }
|
|
)
|
|
vim.api.nvim_set_hl(0, 'DiffsAddText', { default = true, bg = blended_add_text })
|
|
vim.api.nvim_set_hl(0, 'DiffsDeleteText', { default = true, bg = blended_del_text })
|
|
|
|
dbg('highlight groups: Normal.bg=#%06x DiffAdd.bg=#%06x diffAdded.fg=#%06x', bg, add_bg, add_fg)
|
|
dbg(
|
|
'DiffsAdd.bg=#%06x DiffsAddText.bg=#%06x DiffsAddNr.fg=#%06x',
|
|
blended_add,
|
|
blended_add_text,
|
|
add_fg
|
|
)
|
|
dbg('DiffsDelete.bg=#%06x DiffsDeleteText.bg=#%06x', blended_del, blended_del_text)
|
|
|
|
local diff_change = resolve_hl('DiffChange')
|
|
local diff_text = resolve_hl('DiffText')
|
|
|
|
vim.api.nvim_set_hl(0, 'DiffsDiffAdd', { default = true, bg = diff_add.bg })
|
|
vim.api.nvim_set_hl(
|
|
0,
|
|
'DiffsDiffDelete',
|
|
{ default = true, fg = diff_delete.fg, bg = diff_delete.bg }
|
|
)
|
|
vim.api.nvim_set_hl(0, 'DiffsDiffChange', { default = true, bg = diff_change.bg })
|
|
vim.api.nvim_set_hl(0, 'DiffsDiffText', { default = true, bg = diff_text.bg })
|
|
|
|
local change_bg = diff_change.bg or 0x3a3a4a
|
|
local text_bg = diff_text.bg or 0x4a4a5a
|
|
local change_fg = diff_change.fg or diff_text.fg or 0x80a0c0
|
|
|
|
local blended_ours = blend_color(add_bg, bg, 0.4)
|
|
local blended_theirs = blend_color(change_bg, bg, 0.4)
|
|
local blended_base = blend_color(text_bg, bg, 0.3)
|
|
local blended_ours_nr = blend_color(add_fg, bg, alpha)
|
|
local blended_theirs_nr = blend_color(change_fg, bg, alpha)
|
|
local blended_base_nr = blend_color(change_fg, bg, 0.4)
|
|
|
|
vim.api.nvim_set_hl(0, 'DiffsConflictOurs', { default = true, bg = blended_ours })
|
|
vim.api.nvim_set_hl(0, 'DiffsConflictTheirs', { default = true, bg = blended_theirs })
|
|
vim.api.nvim_set_hl(0, 'DiffsConflictBase', { default = true, bg = blended_base })
|
|
vim.api.nvim_set_hl(0, 'DiffsConflictMarker', { default = true, fg = 0x808080, bold = true })
|
|
vim.api.nvim_set_hl(
|
|
0,
|
|
'DiffsConflictOursNr',
|
|
{ default = true, fg = blended_ours_nr, bg = blended_ours }
|
|
)
|
|
vim.api.nvim_set_hl(
|
|
0,
|
|
'DiffsConflictTheirsNr',
|
|
{ default = true, fg = blended_theirs_nr, bg = blended_theirs }
|
|
)
|
|
vim.api.nvim_set_hl(
|
|
0,
|
|
'DiffsConflictBaseNr',
|
|
{ default = true, fg = blended_base_nr, bg = blended_base }
|
|
)
|
|
|
|
if config.highlights.overrides then
|
|
for group, hl in pairs(config.highlights.overrides) do
|
|
vim.api.nvim_set_hl(0, group, hl)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function init()
|
|
if initialized then
|
|
return
|
|
end
|
|
initialized = true
|
|
|
|
local opts = vim.g.diffs or {}
|
|
|
|
vim.validate({
|
|
debug = { opts.debug, 'boolean', true },
|
|
debounce_ms = { opts.debounce_ms, 'number', true },
|
|
hide_prefix = { opts.hide_prefix, 'boolean', true },
|
|
highlights = { opts.highlights, 'table', true },
|
|
})
|
|
|
|
if opts.highlights then
|
|
vim.validate({
|
|
['highlights.background'] = { opts.highlights.background, 'boolean', true },
|
|
['highlights.gutter'] = { opts.highlights.gutter, 'boolean', true },
|
|
['highlights.blend_alpha'] = { opts.highlights.blend_alpha, 'number', true },
|
|
['highlights.overrides'] = { opts.highlights.overrides, 'table', true },
|
|
['highlights.context'] = { opts.highlights.context, 'table', true },
|
|
['highlights.treesitter'] = { opts.highlights.treesitter, 'table', true },
|
|
['highlights.vim'] = { opts.highlights.vim, 'table', true },
|
|
['highlights.intra'] = { opts.highlights.intra, 'table', true },
|
|
})
|
|
|
|
if opts.highlights.context then
|
|
vim.validate({
|
|
['highlights.context.enabled'] = { opts.highlights.context.enabled, 'boolean', true },
|
|
['highlights.context.lines'] = { opts.highlights.context.lines, 'number', true },
|
|
})
|
|
end
|
|
|
|
if opts.highlights.treesitter then
|
|
vim.validate({
|
|
['highlights.treesitter.enabled'] = { opts.highlights.treesitter.enabled, 'boolean', true },
|
|
['highlights.treesitter.max_lines'] = {
|
|
opts.highlights.treesitter.max_lines,
|
|
'number',
|
|
true,
|
|
},
|
|
})
|
|
end
|
|
|
|
if opts.highlights.vim then
|
|
vim.validate({
|
|
['highlights.vim.enabled'] = { opts.highlights.vim.enabled, 'boolean', true },
|
|
['highlights.vim.max_lines'] = { opts.highlights.vim.max_lines, 'number', true },
|
|
})
|
|
end
|
|
|
|
if opts.highlights.intra then
|
|
vim.validate({
|
|
['highlights.intra.enabled'] = { opts.highlights.intra.enabled, 'boolean', true },
|
|
['highlights.intra.algorithm'] = {
|
|
opts.highlights.intra.algorithm,
|
|
function(v)
|
|
return v == nil or v == 'default' or v == 'vscode'
|
|
end,
|
|
"'default' or 'vscode'",
|
|
},
|
|
['highlights.intra.max_lines'] = { opts.highlights.intra.max_lines, 'number', true },
|
|
})
|
|
end
|
|
end
|
|
|
|
if opts.fugitive then
|
|
vim.validate({
|
|
['fugitive.horizontal'] = {
|
|
opts.fugitive.horizontal,
|
|
function(v)
|
|
return v == false or type(v) == 'string'
|
|
end,
|
|
'string or false',
|
|
},
|
|
['fugitive.vertical'] = {
|
|
opts.fugitive.vertical,
|
|
function(v)
|
|
return v == false or type(v) == 'string'
|
|
end,
|
|
'string or false',
|
|
},
|
|
})
|
|
end
|
|
|
|
if opts.conflict then
|
|
vim.validate({
|
|
['conflict.enabled'] = { opts.conflict.enabled, 'boolean', true },
|
|
['conflict.disable_diagnostics'] = { opts.conflict.disable_diagnostics, 'boolean', true },
|
|
['conflict.show_virtual_text'] = { opts.conflict.show_virtual_text, 'boolean', true },
|
|
['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true },
|
|
})
|
|
|
|
if opts.conflict.keymaps then
|
|
local keymap_validator = function(v)
|
|
return v == false or type(v) == 'string'
|
|
end
|
|
for _, key in ipairs({ 'ours', 'theirs', 'both', 'none', 'next', 'prev' }) do
|
|
vim.validate({
|
|
['conflict.keymaps.' .. key] = {
|
|
opts.conflict.keymaps[key],
|
|
keymap_validator,
|
|
'string or false',
|
|
},
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
if opts.debounce_ms and opts.debounce_ms < 0 then
|
|
error('diffs: debounce_ms must be >= 0')
|
|
end
|
|
if
|
|
opts.highlights
|
|
and opts.highlights.context
|
|
and opts.highlights.context.lines
|
|
and opts.highlights.context.lines < 0
|
|
then
|
|
error('diffs: highlights.context.lines must be >= 0')
|
|
end
|
|
if
|
|
opts.highlights
|
|
and opts.highlights.treesitter
|
|
and opts.highlights.treesitter.max_lines
|
|
and opts.highlights.treesitter.max_lines < 1
|
|
then
|
|
error('diffs: highlights.treesitter.max_lines must be >= 1')
|
|
end
|
|
if
|
|
opts.highlights
|
|
and opts.highlights.vim
|
|
and opts.highlights.vim.max_lines
|
|
and opts.highlights.vim.max_lines < 1
|
|
then
|
|
error('diffs: highlights.vim.max_lines must be >= 1')
|
|
end
|
|
if
|
|
opts.highlights
|
|
and opts.highlights.intra
|
|
and opts.highlights.intra.max_lines
|
|
and opts.highlights.intra.max_lines < 1
|
|
then
|
|
error('diffs: highlights.intra.max_lines must be >= 1')
|
|
end
|
|
if
|
|
opts.highlights
|
|
and opts.highlights.blend_alpha
|
|
and (opts.highlights.blend_alpha < 0 or opts.highlights.blend_alpha > 1)
|
|
then
|
|
error('diffs: highlights.blend_alpha must be >= 0 and <= 1')
|
|
end
|
|
|
|
config = vim.tbl_deep_extend('force', default_config, opts)
|
|
log.set_enabled(config.debug)
|
|
|
|
compute_highlight_groups()
|
|
|
|
vim.api.nvim_create_autocmd('ColorScheme', {
|
|
callback = function()
|
|
compute_highlight_groups()
|
|
for bufnr, _ in pairs(attached_buffers) do
|
|
highlight_buffer(bufnr)
|
|
end
|
|
end,
|
|
})
|
|
|
|
vim.api.nvim_create_autocmd('WinClosed', {
|
|
callback = function(args)
|
|
local win = tonumber(args.match)
|
|
if win and diff_windows[win] then
|
|
diff_windows[win] = nil
|
|
end
|
|
end,
|
|
})
|
|
end
|
|
|
|
---@param bufnr? integer
|
|
function M.attach(bufnr)
|
|
init()
|
|
bufnr = bufnr or vim.api.nvim_get_current_buf()
|
|
|
|
if attached_buffers[bufnr] then
|
|
return
|
|
end
|
|
attached_buffers[bufnr] = true
|
|
|
|
dbg('attaching to buffer %d', bufnr)
|
|
|
|
local debounced = create_debounced_highlight(bufnr)
|
|
|
|
highlight_buffer(bufnr)
|
|
|
|
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, {
|
|
buffer = bufnr,
|
|
callback = debounced,
|
|
})
|
|
|
|
vim.api.nvim_create_autocmd('Syntax', {
|
|
buffer = bufnr,
|
|
callback = function()
|
|
dbg('syntax event, re-highlighting buffer %d', bufnr)
|
|
highlight_buffer(bufnr)
|
|
end,
|
|
})
|
|
|
|
vim.api.nvim_create_autocmd('BufReadPost', {
|
|
buffer = bufnr,
|
|
callback = function()
|
|
dbg('BufReadPost event, re-highlighting buffer %d', bufnr)
|
|
highlight_buffer(bufnr)
|
|
end,
|
|
})
|
|
|
|
vim.api.nvim_create_autocmd('BufWipeout', {
|
|
buffer = bufnr,
|
|
callback = function()
|
|
attached_buffers[bufnr] = nil
|
|
end,
|
|
})
|
|
end
|
|
|
|
---@param bufnr? integer
|
|
function M.refresh(bufnr)
|
|
bufnr = bufnr or vim.api.nvim_get_current_buf()
|
|
highlight_buffer(bufnr)
|
|
end
|
|
|
|
local DIFF_WINHIGHLIGHT = table.concat({
|
|
'DiffAdd:DiffsDiffAdd',
|
|
'DiffDelete:DiffsDiffDelete',
|
|
'DiffChange:DiffsDiffChange',
|
|
'DiffText:DiffsDiffText',
|
|
}, ',')
|
|
|
|
function M.attach_diff()
|
|
init()
|
|
local tabpage = vim.api.nvim_get_current_tabpage()
|
|
local wins = vim.api.nvim_tabpage_list_wins(tabpage)
|
|
|
|
local diff_wins = {}
|
|
|
|
for _, win in ipairs(wins) do
|
|
if vim.api.nvim_win_is_valid(win) and vim.wo[win].diff then
|
|
table.insert(diff_wins, win)
|
|
end
|
|
end
|
|
|
|
if #diff_wins == 0 then
|
|
return
|
|
end
|
|
|
|
for _, win in ipairs(diff_wins) do
|
|
vim.api.nvim_set_option_value('winhighlight', DIFF_WINHIGHLIGHT, { win = win })
|
|
diff_windows[win] = true
|
|
dbg('applied diff winhighlight to window %d', win)
|
|
end
|
|
end
|
|
|
|
function M.detach_diff()
|
|
for win, _ in pairs(diff_windows) do
|
|
if vim.api.nvim_win_is_valid(win) then
|
|
vim.api.nvim_set_option_value('winhighlight', '', { win = win })
|
|
end
|
|
diff_windows[win] = nil
|
|
end
|
|
end
|
|
|
|
---@return diffs.FugitiveConfig
|
|
function M.get_fugitive_config()
|
|
init()
|
|
return config.fugitive
|
|
end
|
|
|
|
---@return diffs.ConflictConfig
|
|
function M.get_conflict_config()
|
|
init()
|
|
return config.conflict
|
|
end
|
|
|
|
return M
|