Compare commits

...

5 commits

Author SHA1 Message Date
2834b588f1 docs: trim readme features list 2026-02-09 13:36:56 -05:00
861f83600a refactor(conflict): keep default virtual text labels minimal
Problem: default labels included keymap hints ("current — doo") which
is an anti-pattern for a vim plugin — discoverability belongs in docs
and opt-in config, not baked into the default UI.

Solution: default labels return to plain "(current)" / "(incoming)".
Keymap hints are only shown when users provide a format_virtual_text
function or enable show_actions.
2026-02-09 13:27:13 -05:00
Barrett Ruth
c9e2722d19
Merge branch 'main' into feat/conflict-ui 2026-02-09 13:24:51 -05:00
6e1a053bc4 feat(conflict): add virtual text formatting and action lines
Problem: conflict resolution virtual text only showed plain "current" /
"incoming" labels with no keymap hints, and users had no way to
discover available keymaps without reading docs.

Solution: add keymap hints to default labels ("current — doo"), expose
format_virtual_text config for custom label formatting, and add
show_actions option for codelens-style action lines above conflict
markers. Also add hunk hints in merge diff views.
2026-02-09 13:05:37 -05:00
Barrett Ruth
f5a090baae
perf: cache repo root and harden async paths (#100)
## Problem

`get_repo_root()` shells out to `git rev-parse` on every call, causing
4-6
redundant subprocesses per `gdiff_file()` invocation. Three other minor
issues: `highlight_vim_syntax()` leaks a scratch buffer if
`nvim_buf_call`
errors, `lib.ensure()` silently drops callbacks during download so hunks
highlighted mid-download permanently miss intra-line highlights, and the
debounce timer callback can operate on a deleted buffer.

## Solution

Cache `get_repo_root()` results by parent directory — repo roots don't
change within a session. Wrap `nvim_buf_call` and `nvim_buf_delete` in
pcall so the scratch buffer is always cleaned up. Replace the early
`callback(nil)` in `lib.ensure()` with a pending callback queue that
fires
once the download completes. Guard the debounce timer callback with
`nvim_buf_is_valid`.
2026-02-09 12:39:13 -05:00
10 changed files with 377 additions and 37 deletions

View file

@ -9,18 +9,12 @@ syntax highlighting.
## Features
- Treesitter syntax highlighting in `:Git` diffs and commit views
- Diff header highlighting (`diff --git`, `index`, `---`, `+++`)
- `:Gdiffsplit` / `:Gvdiffsplit` syntax through diff backgrounds
- `:Gdiff` unified diff against any git revision with syntax highlighting
- Fugitive status buffer keymaps (`du`/`dU`) for unified diffs
- Background-only diff colors for any `&diff` buffer (`:diffthis`, `vimdiff`)
- Vim syntax fallback for languages without a treesitter parser
- Hunk header context highlighting (`@@ ... @@ function foo()`)
- Character-level (intra-line) diff highlighting for changed characters
- Inline merge conflict detection, highlighting, and resolution keymaps
- Configurable debouncing, max lines, diff prefix concealment, blend alpha, and
highlight overrides
- Treesitter syntax highlighting in fugitive diffs and commit views
- Character-level intra-line diff highlighting
- `:Gdiff` unified diff against any revision
- Background-only diff colors for `&diff` buffers
- Inline merge conflict detection, highlighting, and resolution
- Vim syntax fallback, context padding, configurable blend/debounce
## Requirements

View file

@ -85,6 +85,7 @@ Configuration is done via `vim.g.diffs`. Set this before the plugin loads:
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
show_actions = false,
keymaps = {
ours = 'doo',
theirs = 'dot',
@ -416,6 +417,7 @@ Configuration: ~
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
show_actions = false,
keymaps = {
ours = 'doo',
theirs = 'dot',
@ -442,9 +444,32 @@ Configuration: ~
diagnostics alone.
{show_virtual_text} (boolean, default: true)
Show virtual text labels (" current" and
" incoming") at the end of `<<<<<<<` and
`>>>>>>>` marker lines.
Show `(current)` and `(incoming)` labels at
the end of `<<<<<<<` and `>>>>>>>` marker
lines. Also controls hunk hints in merge
diff views.
{format_virtual_text} (function|nil, default: nil)
Custom formatter for virtual text labels.
Receives `(side, keymap)` where `side` is
`"ours"` or `"theirs"` and `keymap` is the
configured keymap string or `false`. Return
a string (label text without parens) or
`nil` to hide the label. Example: >lua
format_virtual_text = function(side, keymap)
if keymap then
return side .. ' [' .. keymap .. ']'
end
return side
end
<
{show_actions} (boolean, default: false)
Show a codelens-style action line above each
`<<<<<<<` marker listing available resolution
keymaps. Renders as virtual lines using the
`DiffsConflictActions` highlight group.
Only keymaps that are not `false` appear.
{keymaps} (table, default: see above)
Buffer-local keymaps for conflict resolution
@ -687,6 +712,10 @@ Conflict highlights: ~
*DiffsConflictBaseNr*
DiffsConflictBaseNr Line number for base content lines (diff3).
*DiffsConflictActions*
DiffsConflictActions Dimmed foreground (no bold) for the codelens-style
action line shown when `show_actions` is true.
Diff mode window highlights: ~
These are used for |winhighlight| remapping in `&diff` windows.

View file

@ -92,6 +92,17 @@ local function parse_buffer(bufnr)
return M.parse(lines)
end
---@param side string
---@param config diffs.ConflictConfig
---@return string?
local function get_virtual_text_label(side, config)
if config.format_virtual_text then
local keymap = side == 'ours' and config.keymaps.ours or config.keymaps.theirs
return config.format_virtual_text(side, keymap)
end
return side == 'ours' and 'current' or 'incoming'
end
---@param bufnr integer
---@param regions diffs.ConflictRegion[]
---@param config diffs.ConflictConfig
@ -107,10 +118,37 @@ local function apply_highlights(bufnr, regions, config)
})
if config.show_virtual_text then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
virt_text = { { ' (current)', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
local ours_label = get_virtual_text_label('ours', config)
if ours_label then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
virt_text = { { ' (' .. ours_label .. ')', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
end
end
if config.show_actions then
local parts = {}
local actions = {
{ 'Current', config.keymaps.ours },
{ 'Incoming', config.keymaps.theirs },
{ 'Both', config.keymaps.both },
{ 'None', config.keymaps.none },
}
for _, action in ipairs(actions) do
if action[2] then
if #parts > 0 then
table.insert(parts, { ' \226\148\130 ', 'DiffsConflictActions' })
end
table.insert(parts, { ('%s (%s)'):format(action[1], action[2]), 'DiffsConflictActions' })
end
end
if #parts > 0 then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, {
virt_lines = { parts },
virt_lines_above = true,
})
end
end
for line = region.ours_start, region.ours_end - 1 do
@ -176,10 +214,13 @@ local function apply_highlights(bufnr, regions, config)
})
if config.show_virtual_text then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, {
virt_text = { { ' (incoming)', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
local theirs_label = get_virtual_text_label('theirs', config)
if theirs_label then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, {
virt_text = { { ' (' .. theirs_label .. ')', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
end
end
end
end

View file

@ -1,13 +1,19 @@
local M = {}
local repo_root_cache = {}
---@param filepath string
---@return string?
function M.get_repo_root(filepath)
local dir = vim.fn.fnamemodify(filepath, ':h')
if repo_root_cache[dir] ~= nil then
return repo_root_cache[dir]
end
local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' })
if vim.v.shell_error ~= 0 then
return nil
end
repo_root_cache[dir] = result[1]
return result[1]
end

View file

@ -238,7 +238,7 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines,
local spans = {}
vim.api.nvim_buf_call(scratch, function()
pcall(vim.api.nvim_buf_call, scratch, function()
vim.cmd('setlocal syntax=' .. ft)
vim.cmd('redraw')
@ -256,7 +256,7 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines,
spans = M.coalesce_syntax_spans(query_fn, code_lines)
end)
vim.api.nvim_buf_delete(scratch, { force = true })
pcall(vim.api.nvim_buf_delete, scratch, { force = true })
local hunk_line_count = #hunk.lines
local extmark_count = 0

View file

@ -41,6 +41,8 @@
---@field enabled boolean
---@field disable_diagnostics boolean
---@field show_virtual_text boolean
---@field format_virtual_text? fun(side: string, keymap: string|false): string?
---@field show_actions boolean
---@field keymaps diffs.ConflictKeymaps
---@class diffs.Config
@ -128,6 +130,7 @@ local default_config = {
enabled = true,
disable_diagnostics = true,
show_virtual_text = true,
show_actions = false,
keymaps = {
ours = 'doo',
theirs = 'dot',
@ -200,7 +203,9 @@ local function create_debounced_highlight(bufnr)
timer = nil
t:close()
end
highlight_buffer(bufnr)
if vim.api.nvim_buf_is_valid(bufnr) then
highlight_buffer(bufnr)
end
end)
)
end
@ -274,6 +279,7 @@ local function compute_highlight_groups()
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, 'DiffsConflictActions', { default = true, fg = 0x808080 })
vim.api.nvim_set_hl(
0,
'DiffsConflictOursNr',
@ -388,6 +394,8 @@ local function init()
['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.format_virtual_text'] = { opts.conflict.format_virtual_text, 'function', true },
['conflict.show_actions'] = { opts.conflict.show_actions, 'boolean', true },
['conflict.keymaps'] = { opts.conflict.keymaps, 'table', true },
})

View file

@ -8,6 +8,9 @@ local cached_handle = nil
---@type boolean
local download_in_progress = false
---@type fun(handle: table?)[]
local pending_callbacks = {}
---@return string
local function get_os()
local os_name = jit.os:lower()
@ -164,9 +167,10 @@ function M.ensure(callback)
return
end
table.insert(pending_callbacks, callback)
if download_in_progress then
dbg('download already in progress')
callback(nil)
dbg('download already in progress, queued callback')
return
end
@ -192,21 +196,25 @@ function M.ensure(callback)
vim.system(cmd, {}, function(result)
download_in_progress = false
vim.schedule(function()
local handle = nil
if result.code ~= 0 then
vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN)
dbg('curl failed: %s', result.stderr or '')
callback(nil)
return
else
local f = io.open(version_path(), 'w')
if f then
f:write(EXPECTED_VERSION)
f:close()
end
vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO)
handle = M.load()
end
local f = io.open(version_path(), 'w')
if f then
f:write(EXPECTED_VERSION)
f:close()
local cbs = pending_callbacks
pending_callbacks = {}
for _, cb in ipairs(cbs) do
cb(handle)
end
vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO)
callback(M.load())
end)
end)
end

View file

@ -338,6 +338,43 @@ function M.goto_prev(bufnr)
vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 })
end
---@param bufnr integer
---@param config diffs.ConflictConfig
local function apply_hunk_hints(bufnr, config)
if not config.show_virtual_text then
return
end
local hunks = M.parse_hunks(bufnr)
for _, hunk in ipairs(hunks) do
if M.is_resolved(bufnr, hunk.index) then
add_resolved_virtual_text(bufnr, hunk)
else
local parts = {}
local actions = {
{ 'current', config.keymaps.ours },
{ 'incoming', config.keymaps.theirs },
{ 'both', config.keymaps.both },
{ 'none', config.keymaps.none },
}
for _, action in ipairs(actions) do
if action[2] then
if #parts > 0 then
table.insert(parts, { ' | ', 'Comment' })
end
table.insert(parts, { ('%s: %s'):format(action[2], action[1]), 'Comment' })
end
end
if #parts > 0 then
pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, hunk.start_line, 0, {
virt_text = parts,
virt_text_pos = 'eol',
})
end
end
end
end
---@param bufnr integer
---@param config diffs.ConflictConfig
function M.setup_keymaps(bufnr, config)
@ -358,6 +395,8 @@ function M.setup_keymaps(bufnr, config)
end
end
apply_hunk_hints(bufnr, config)
vim.api.nvim_create_autocmd('BufWipeout', {
buffer = bufnr,
callback = function()

View file

@ -6,6 +6,7 @@ local function default_config(overrides)
enabled = true,
disable_diagnostics = false,
show_virtual_text = true,
show_actions = false,
keymaps = {
ours = 'doo',
theirs = 'dot',
@ -685,4 +686,158 @@ describe('conflict', function()
helpers.delete_buffer(bufnr)
end)
end)
describe('virtual text formatting', function()
after_each(function()
conflict.detach(vim.api.nvim_get_current_buf())
end)
it('default labels show current and incoming without keymaps', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config())
local extmarks = get_extmarks(bufnr)
local labels = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
table.insert(labels, mark[4].virt_text[1][1])
end
end
assert.are.equal(2, #labels)
assert.are.equal(' (current)', labels[1])
assert.are.equal(' (incoming)', labels[2])
helpers.delete_buffer(bufnr)
end)
it('uses custom format_virtual_text function', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(
bufnr,
default_config({
format_virtual_text = function(side)
return side == 'ours' and 'OURS' or 'THEIRS'
end,
})
)
local extmarks = get_extmarks(bufnr)
local labels = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
table.insert(labels, mark[4].virt_text[1][1])
end
end
assert.are.equal(2, #labels)
assert.are.equal(' (OURS)', labels[1])
assert.are.equal(' (THEIRS)', labels[2])
helpers.delete_buffer(bufnr)
end)
it('hides label when format_virtual_text returns nil', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(
bufnr,
default_config({
format_virtual_text = function()
return nil
end,
})
)
local extmarks = get_extmarks(bufnr)
local virt_text_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
virt_text_count = virt_text_count + 1
end
end
assert.are.equal(0, virt_text_count)
helpers.delete_buffer(bufnr)
end)
end)
describe('action lines', function()
after_each(function()
conflict.detach(vim.api.nvim_get_current_buf())
end)
it('adds virt_lines when show_actions is true', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(bufnr, default_config({ show_actions = true }))
local extmarks = get_extmarks(bufnr)
local virt_lines_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_lines then
virt_lines_count = virt_lines_count + 1
end
end
assert.are.equal(1, virt_lines_count)
helpers.delete_buffer(bufnr)
end)
it('omits disabled keymaps from action line', function()
local bufnr = create_file_buffer({
'<<<<<<< HEAD',
'local x = 1',
'=======',
'local x = 2',
'>>>>>>> feature',
})
conflict.attach(
bufnr,
default_config({ show_actions = true, keymaps = { both = false, none = false } })
)
local extmarks = get_extmarks(bufnr)
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_lines then
local line = mark[4].virt_lines[1]
local text = ''
for _, chunk in ipairs(line) do
text = text .. chunk[1]
end
assert.is_truthy(text:find('Current'))
assert.is_truthy(text:find('Incoming'))
assert.is_falsy(text:find('Both'))
assert.is_falsy(text:find('None'))
end
end
helpers.delete_buffer(bufnr)
end)
end)
end)

View file

@ -6,6 +6,7 @@ local function default_config(overrides)
enabled = true,
disable_diagnostics = false,
show_virtual_text = true,
show_actions = false,
keymaps = {
ours = 'doo',
theirs = 'dot',
@ -617,6 +618,65 @@ describe('merge', function()
end)
end)
describe('hunk hints', function()
it('adds keymap hints on hunk header lines', function()
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local x = 1',
'+local x = 2',
})
merge.setup_keymaps(d_bufnr, default_config())
local extmarks =
vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true })
local hint_marks = {}
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
local text = ''
for _, chunk in ipairs(mark[4].virt_text) do
text = text .. chunk[1]
end
table.insert(hint_marks, { line = mark[2], text = text })
end
end
assert.are.equal(1, #hint_marks)
assert.are.equal(3, hint_marks[1].line)
assert.is_truthy(hint_marks[1].text:find('doo'))
assert.is_truthy(hint_marks[1].text:find('dot'))
helpers.delete_buffer(d_bufnr)
end)
it('does not add hints when show_virtual_text is false', function()
local d_bufnr = create_diff_buffer({
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1,1 +1,1 @@',
'-local x = 1',
'+local x = 2',
})
merge.setup_keymaps(d_bufnr, default_config({ show_virtual_text = false }))
local extmarks =
vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true })
local virt_text_count = 0
for _, mark in ipairs(extmarks) do
if mark[4] and mark[4].virt_text then
virt_text_count = virt_text_count + 1
end
end
assert.are.equal(0, virt_text_count)
helpers.delete_buffer(d_bufnr)
end)
end)
describe('fugitive integration', function()
it('parse_file_line returns status for unmerged files', function()
local fugitive = require('diffs.fugitive')