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.
This commit is contained in:
parent
a2053a132b
commit
6e1a053bc4
6 changed files with 375 additions and 11 deletions
|
|
@ -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,35 @@ Configuration: ~
|
|||
diagnostics alone.
|
||||
|
||||
{show_virtual_text} (boolean, default: true)
|
||||
Show virtual text labels (" current" and
|
||||
" incoming") at the end of `<<<<<<<` and
|
||||
`>>>>>>>` marker lines.
|
||||
Show virtual text labels at the end of
|
||||
`<<<<<<<` and `>>>>>>>` marker lines.
|
||||
Default labels include keymap hints:
|
||||
`(current — doo)` and `(incoming — dot)`.
|
||||
If a keymap is `false`, the hint is omitted.
|
||||
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 +715,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,21 @@ 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)
|
||||
local keymap = side == 'ours' and config.keymaps.ours or config.keymaps.theirs
|
||||
if config.format_virtual_text then
|
||||
return config.format_virtual_text(side, keymap)
|
||||
end
|
||||
local label = side == 'ours' and 'current' or 'incoming'
|
||||
if keymap then
|
||||
return ('%s \226\128\148 %s'):format(label, keymap)
|
||||
end
|
||||
return label
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
---@param regions diffs.ConflictRegion[]
|
||||
---@param config diffs.ConflictConfig
|
||||
|
|
@ -107,10 +122,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 +218,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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -274,6 +277,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 +392,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 },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,185 @@ 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('includes keymap hints in default virtual text', 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.is_truthy(labels[1]:find('current'))
|
||||
assert.is_truthy(labels[1]:find('doo'))
|
||||
assert.is_truthy(labels[2]:find('incoming'))
|
||||
assert.is_truthy(labels[2]:find('dot'))
|
||||
|
||||
helpers.delete_buffer(bufnr)
|
||||
end)
|
||||
|
||||
it('omits keymap from label when keymap is false', function()
|
||||
local bufnr = create_file_buffer({
|
||||
'<<<<<<< HEAD',
|
||||
'local x = 1',
|
||||
'=======',
|
||||
'local x = 2',
|
||||
'>>>>>>> feature',
|
||||
})
|
||||
|
||||
conflict.attach(bufnr, default_config({ keymaps = { ours = false, theirs = false } }))
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue