From dc0774977c05f0cbc3f00222448feb041999b966 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Sat, 25 Apr 2026 14:52:25 -0400 Subject: [PATCH] add merge conflict-state minimap --- lua/glance/config.lua | 14 +- lua/glance/init.lua | 9 +- lua/glance/merge/actions.lua | 7 +- lua/glance/merge/init.lua | 7 + lua/glance/merge/model.lua | 24 +- lua/glance/merge/render.lua | 32 +- lua/glance/minimap.lua | 79 ++++- lua/glance/minimap_logic.lua | 47 +++ lua/glance/theme_presets.lua | 2 + tests/integration/diffview_spec.lua | 101 ++++-- tests/unit/config_spec.lua | 8 +- tests/unit/merge_model_spec.lua | 470 ++++++---------------------- tests/unit/minimap_logic_spec.lua | 45 +++ 13 files changed, 395 insertions(+), 450 deletions(-) diff --git a/lua/glance/config.lua b/lua/glance/config.lua index 2dd55f4..583a72d 100644 --- a/lua/glance/config.lua +++ b/lua/glance/config.lua @@ -56,6 +56,7 @@ local ALLOWED_THEME_PALETTE = { added = true, deleted = true, changed = true, + manual = true, minimap_bg = true, minimap_viewport_bg = true, minimap_cursor = true, @@ -95,8 +96,7 @@ local ALLOWED_MERGE_KEYMAPS = { accept_theirs = true, accept_both_ours_then_theirs = true, accept_both_theirs_then_ours = true, - ignore_ours = true, - ignore_theirs = true, + keep_base = true, reset_conflict = true, mark_resolved = true, } @@ -106,8 +106,7 @@ local MERGE_KEYMAP_ORDER = { 'accept_theirs', 'accept_both_ours_then_theirs', 'accept_both_theirs_then_ours', - 'ignore_ours', - 'ignore_theirs', + 'keep_base', 'reset_conflict', 'mark_resolved', } @@ -253,10 +252,9 @@ local BASE_DEFAULTS = { keymaps = { accept_ours = 'o', accept_theirs = 't', - accept_both_ours_then_theirs = 'b', - accept_both_theirs_then_ours = 'B', - ignore_ours = 'O', - ignore_theirs = 'T', + accept_both_ours_then_theirs = 'O', + accept_both_theirs_then_ours = 'T', + keep_base = 'b', reset_conflict = 'r', mark_resolved = 'm', }, diff --git a/lua/glance/init.lua b/lua/glance/init.lua index 090f60a..81a479a 100644 --- a/lua/glance/init.lua +++ b/lua/glance/init.lua @@ -151,13 +151,14 @@ function M.setup_highlights() local keyword = palette.keyword local func = palette.func local type_color = palette.type + local manual_color = palette.manual or palette.number or palette.keyword local number = palette.number local param = palette.accent local selection = palette.selection local line_hl = palette.line_highlight - local merge_unresolved_bg = blend_hex(bg, palette.logo, 0.16) + local merge_unresolved_bg = blend_hex(bg, palette.changed, 0.2) local merge_handled_bg = blend_hex(bg, palette.added, 0.16) - local merge_manual_bg = blend_hex(bg, type_color, 0.14) + local merge_manual_bg = blend_hex(bg, manual_color, 0.18) -- Editor vim.api.nvim_set_hl(0, 'Normal', { bg = bg, fg = fg }) @@ -257,9 +258,9 @@ function M.setup_highlights() vim.api.nvim_set_hl(0, 'GlanceAccentText', { fg = param, bold = true }) vim.api.nvim_set_hl(0, 'GlanceLegendText', { fg = comment, bg = bg }) vim.api.nvim_set_hl(0, 'GlanceLegendHint', { fg = palette.accent, bg = bg, bold = true }) - vim.api.nvim_set_hl(0, 'GlanceConflictMarkerUnresolved', { fg = palette.logo, bg = bg, bold = true }) + vim.api.nvim_set_hl(0, 'GlanceConflictMarkerUnresolved', { fg = palette.changed, bg = bg, bold = true }) vim.api.nvim_set_hl(0, 'GlanceConflictMarkerHandled', { fg = palette.added, bg = bg, bold = true }) - vim.api.nvim_set_hl(0, 'GlanceConflictMarkerManual', { fg = type_color, bg = bg, bold = true }) + vim.api.nvim_set_hl(0, 'GlanceConflictMarkerManual', { fg = manual_color, bg = bg, bold = true }) vim.api.nvim_set_hl(0, 'GlanceConflictStateUnresolved', { bg = merge_unresolved_bg }) vim.api.nvim_set_hl(0, 'GlanceConflictStateHandled', { bg = merge_handled_bg }) vim.api.nvim_set_hl(0, 'GlanceConflictStateManual', { bg = merge_manual_bg }) diff --git a/lua/glance/merge/actions.lua b/lua/glance/merge/actions.lua index 23876b5..956c0ad 100644 --- a/lua/glance/merge/actions.lua +++ b/lua/glance/merge/actions.lua @@ -7,8 +7,7 @@ local DEFINITIONS = { { id = 'accept_theirs', short = 'theirs' }, { id = 'accept_both_ours_then_theirs', short = 'both o/t' }, { id = 'accept_both_theirs_then_ours', short = 'both t/o' }, - { id = 'ignore_ours', short = 'skip ours' }, - { id = 'ignore_theirs', short = 'skip theirs' }, + { id = 'keep_base', short = 'base' }, { id = 'reset_conflict', short = 'reset' }, { id = 'mark_resolved', short = 'resolve' }, } @@ -33,10 +32,6 @@ function M.available(conflict) if conflict.state == 'manual_unresolved' then actions[#actions + 1] = definition end - elseif id == 'ignore_ours' or id == 'ignore_theirs' then - if conflict.state ~= 'manual_unresolved' and conflict.state ~= 'manual_resolved' then - actions[#actions + 1] = definition - end else actions[#actions + 1] = definition end diff --git a/lua/glance/merge/init.lua b/lua/glance/merge/init.lua index 0719b90..d1936b3 100644 --- a/lua/glance/merge/init.lua +++ b/lua/glance/merge/init.lua @@ -41,6 +41,7 @@ local function refresh_decorations(diffview) end render.decorate(panes(diffview), state.model, state.active_conflict_index) + require('glance.minimap').update_merge(state.model, state.active_conflict_index) end local function result_win(diffview) @@ -464,6 +465,7 @@ local function rebuild(diffview, file) active_conflict_index = state.active_conflict_index, }) layout.equalize(diffview) + require('glance.minimap').update_merge(state.model, state.active_conflict_index) return merge_model end @@ -491,6 +493,11 @@ function M.open(diffview, file) rebuild(diffview, file) + local win = result_win(diffview) + if win then + require('glance.minimap').open_merge(win, state.model, state.active_conflict_index) + end + local root = git.repo_root() if root and config.options.watch.enabled then diffview.watch_file(root .. '/' .. file.path) diff --git a/lua/glance/merge/model.lua b/lua/glance/merge/model.lua index 4b435df..e4c4142 100644 --- a/lua/glance/merge/model.lua +++ b/lua/glance/merge/model.lua @@ -1130,28 +1130,16 @@ function M.apply_action(merge_model, index, action) if action == 'accept_ours' or action == 'accept_theirs' then local side = action == 'accept_ours' and 'ours' or 'theirs' conflict.state = side == 'ours' and 'ours' or 'theirs' - if side == 'ours' then - conflict.ours_handled = true - else - conflict.theirs_handled = true - end + conflict.ours_handled = true + conflict.theirs_handled = true finalize_conflict_action(conflict) return conflict end - if action == 'ignore_ours' or action == 'ignore_theirs' then - if conflict.state == 'manual_unresolved' or conflict.state == 'manual_resolved' then - return nil, 'ignore actions are not available for manual merge states' - end - - if conflict.state == 'unresolved' then - conflict.state = 'base_only' - end - if action == 'ignore_ours' then - conflict.ours_handled = true - else - conflict.theirs_handled = true - end + if action == 'keep_base' then + conflict.state = 'base_only' + conflict.ours_handled = true + conflict.theirs_handled = true finalize_conflict_action(conflict) return conflict end diff --git a/lua/glance/merge/render.lua b/lua/glance/merge/render.lua index 6043eaa..9d078a9 100644 --- a/lua/glance/merge/render.lua +++ b/lua/glance/merge/render.lua @@ -73,7 +73,7 @@ local function conflict_state_label(conflict) end local function conflict_marker_group(conflict) - if conflict.state == 'manual_unresolved' or conflict.state == 'manual_resolved' then + if conflict.state == 'manual_unresolved' then return 'GlanceConflictMarkerManual' end if conflict.handled then @@ -83,7 +83,7 @@ local function conflict_marker_group(conflict) end local function conflict_state_group(conflict) - if conflict.state == 'manual_unresolved' or conflict.state == 'manual_resolved' then + if conflict.state == 'manual_unresolved' then return 'GlanceConflictStateManual' end if conflict.handled then @@ -92,6 +92,15 @@ local function conflict_state_group(conflict) return 'GlanceConflictStateUnresolved' end +local function action_bar_padding() + local minimap = config.options.minimap or {} + if not minimap.enabled then + return '' + end + + return string.rep(' ', (minimap.width or 1) + 1) +end + local function result_label(model, active_conflict_index) local op = model.operation or {} local parts = {} @@ -114,7 +123,7 @@ local function result_label(model, active_conflict_index) local label = table.concat(parts, ' | ') local hint = conflict and actions.hint_text(conflict, config.options.merge.keymaps) or '' if hint ~= '' then - return label .. '%=' .. hint + return label .. '%=' .. hint .. action_bar_padding() end return label end @@ -191,7 +200,16 @@ local function add_result_range(buf, start_line, count, line_group, opts) end local function zero_line_placeholder(conflict) - return '[' .. conflict_state_label(conflict) .. ']' + return ' ' .. conflict_state_label(conflict) .. ' insert' +end + +local function zero_line_anchor(conflict, line_count) + local start_line = conflict.result_range.start or 1 + if start_line <= line_count then + return math.max(start_line, 1) - 1, true + end + + return math.max(line_count, 1) - 1, false end local function decorate_sources(buffers, model, active_conflict_index) @@ -210,11 +228,12 @@ local function decorate_sources(buffers, model, active_conflict_index) number_group = active_number_group, }) - local line_count = math.max(vim.api.nvim_buf_line_count(buffers.result), 1) - local anchor_line = math.min(math.max(conflict.result_range.start, 1), line_count) - 1 if conflict.result_range.count == 0 then + local line_count = math.max(vim.api.nvim_buf_line_count(buffers.result), 1) + local anchor_line, virt_lines_above = zero_line_anchor(conflict, line_count) local extmark_opts = { virt_lines = { { { zero_line_placeholder(conflict), conflict_marker_group(conflict) } } }, + virt_lines_above = virt_lines_above, priority = 100, } if active_number_group then @@ -223,6 +242,7 @@ local function decorate_sources(buffers, model, active_conflict_index) vim.api.nvim_buf_set_extmark(buffers.result, NS, anchor_line, 0, { virt_lines = extmark_opts.virt_lines, + virt_lines_above = extmark_opts.virt_lines_above, priority = extmark_opts.priority, number_hl_group = extmark_opts.number_hl_group, }) diff --git a/lua/glance/minimap.lua b/lua/glance/minimap.lua index 05b52ad..9c8c19a 100644 --- a/lua/glance/minimap.lua +++ b/lua/glance/minimap.lua @@ -17,7 +17,10 @@ local CONTENT_DEBOUNCE_MS = 120 M.buf = nil M.win = nil M.target_win = nil +M.mode = nil M.old_lines = nil -- cached old content for vim.diff() +M.merge_model = nil +M.active_conflict_index = nil M.cached_pixels = nil -- cached pixel states (only recomputed on content change) M.pixel_count = 0 M.total_logical = 0 @@ -37,6 +40,10 @@ local ADD = logic.states.ADD local DELETE = logic.states.DELETE local CHANGE = logic.states.CHANGE local CURSOR = logic.states.CURSOR +local MERGE_UNRESOLVED = logic.states.MERGE_UNRESOLVED +local MERGE_HANDLED = logic.states.MERGE_HANDLED +local MERGE_MANUAL = logic.states.MERGE_MANUAL +local MERGE_ACTIVE = logic.states.MERGE_ACTIVE -- Dynamic highlight group cache local hl_cache = {} @@ -51,18 +58,27 @@ end local function colors() local palette = config.options.theme.palette + local manual = palette.manual or palette.number or palette.keyword return { [NONE] = palette.minimap_bg, [ADD] = palette.added, [DELETE] = palette.deleted, [CHANGE] = palette.changed, [CURSOR] = palette.minimap_cursor, + [MERGE_UNRESOLVED] = palette.changed, + [MERGE_HANDLED] = palette.added, + [MERGE_MANUAL] = manual, + [MERGE_ACTIVE] = palette.minimap_cursor, }, { [NONE] = palette.minimap_viewport_bg, [ADD] = palette.added, [DELETE] = palette.deleted, [CHANGE] = palette.changed, [CURSOR] = palette.minimap_cursor, + [MERGE_UNRESOLVED] = palette.changed, + [MERGE_HANDLED] = palette.added, + [MERGE_MANUAL] = manual, + [MERGE_ACTIVE] = palette.minimap_cursor, } end @@ -126,7 +142,10 @@ local function has_valid_state() return M.buf and vim.api.nvim_buf_is_valid(M.buf) and M.win and vim.api.nvim_win_is_valid(M.win) and M.target_win and vim.api.nvim_win_is_valid(M.target_win) - and M.old_lines ~= nil + and ( + (M.mode == 'diff' and M.old_lines ~= nil) + or (M.mode == 'merge' and M.merge_model ~= nil) + ) end local function target_buf() @@ -335,7 +354,16 @@ function M.full_update(opts) end local new_lines = vim.api.nvim_buf_get_lines(new_buf, 0, -1, false) - local line_types, total_lines = logic.compute_line_types(M.old_lines, new_lines) + local line_types, total_lines + if M.mode == 'merge' then + line_types, total_lines = logic.compute_merge_line_types( + M.merge_model.conflicts, + math.max(#new_lines, vim.api.nvim_buf_line_count(new_buf)), + M.active_conflict_index + ) + else + line_types, total_lines = logic.compute_line_types(M.old_lines, new_lines) + end M.total_logical = total_lines M.cached_pixels = logic.downsample(line_types, total_lines, pixel_count) M.last_changedtick = changedtick @@ -346,17 +374,11 @@ function M.full_update(opts) render_from_cache() end ---- Create the floating window and buffer, then do initial render. ---- @param target_win number The new (right) diff pane window ---- @param old_lines string[] Old file content for diff computation -function M.open(target_win, old_lines) - M.close() - +local function open_window(target_win) if not minimap_config().enabled then return end if not target_win or not vim.api.nvim_win_is_valid(target_win) then return end M.target_win = target_win - M.old_lines = old_lines -- Create scratch buffer M.buf = vim.api.nvim_create_buf(false, true) @@ -389,6 +411,42 @@ function M.open(target_win, old_lines) M.setup_autocmds() end +--- Create the floating window and buffer, then do initial render. +--- @param target_win number The new (right) diff pane window +--- @param old_lines string[] Old file content for diff computation +function M.open(target_win, old_lines) + M.close() + M.mode = 'diff' + M.old_lines = old_lines + M.merge_model = nil + M.active_conflict_index = nil + open_window(target_win) +end + +--- Create a merge minimap on the Result pane. +--- @param target_win number +--- @param merge_model table +--- @param active_conflict_index integer|nil +function M.open_merge(target_win, merge_model, active_conflict_index) + M.close() + M.mode = 'merge' + M.old_lines = nil + M.merge_model = merge_model + M.active_conflict_index = active_conflict_index + open_window(target_win) +end + +--- Update merge minimap state after conflict actions, edits, or active conflict changes. +function M.update_merge(merge_model, active_conflict_index) + if M.mode ~= 'merge' or not M.win or not vim.api.nvim_win_is_valid(M.win) then + return + end + + M.merge_model = merge_model + M.active_conflict_index = active_conflict_index + M.full_update({ force_recompute = true }) +end + --- Set up autocmds for scroll sync and content change tracking. function M.setup_autocmds() vim.api.nvim_clear_autocmds({ group = M.augroup }) @@ -443,7 +501,10 @@ function M.close() M.win = nil M.buf = nil M.target_win = nil + M.mode = nil M.old_lines = nil + M.merge_model = nil + M.active_conflict_index = nil M.cached_pixels = nil M.pixel_count = 0 M.total_logical = 0 diff --git a/lua/glance/minimap_logic.lua b/lua/glance/minimap_logic.lua index 789279b..b01d54d 100644 --- a/lua/glance/minimap_logic.lua +++ b/lua/glance/minimap_logic.lua @@ -6,6 +6,10 @@ M.states = { DELETE = 2, CHANGE = 3, CURSOR = 4, + MERGE_UNRESOLVED = 5, + MERGE_HANDLED = 6, + MERGE_MANUAL = 7, + MERGE_ACTIVE = 8, } --- Extract diff regions from old_lines vs new_lines using vim.diff(). @@ -49,6 +53,49 @@ function M.compute_line_types(old_lines, new_lines) return line_types, total end +local function conflict_state(conflict, is_active) + if is_active then + return M.states.MERGE_ACTIVE + end + if conflict.state == 'manual_unresolved' then + return M.states.MERGE_MANUAL + end + if conflict.handled then + return M.states.MERGE_HANDLED + end + return M.states.MERGE_UNRESOLVED +end + +--- Convert merge conflict result ranges into minimap line states. +--- Zero-line conflicts are anchored to their insertion line so they remain visible. +--- @param conflicts table[] +--- @param total_lines integer +--- @param active_conflict_index integer|nil +--- @return table, integer +function M.compute_merge_line_types(conflicts, total_lines, active_conflict_index) + total_lines = math.max(total_lines or 0, 1) + local line_types = {} + + for index, conflict in ipairs(conflicts or {}) do + local range = conflict.result_range or {} + local start_line = range.start or 1 + local count = range.count or 0 + local state = conflict_state(conflict, index == active_conflict_index) + + if count == 0 then + local mark = math.max(1, math.min(start_line, total_lines)) + line_types[mark] = state + else + local stop_line = math.min(start_line + count - 1, total_lines) + for lnum = math.max(start_line, 1), stop_line do + line_types[lnum] = state + end + end + end + + return line_types, total_lines +end + --- Downsample line_types into pixel_count logical pixels. --- Each pixel represents a proportional range of file lines. --- @param line_types table diff --git a/lua/glance/theme_presets.lua b/lua/glance/theme_presets.lua index 04cc572..8fd8d0e 100644 --- a/lua/glance/theme_presets.lua +++ b/lua/glance/theme_presets.lua @@ -15,6 +15,7 @@ local M = { added = '#2ea043', deleted = '#f85149', changed = '#d29922', + manual = '#B48CFF', minimap_bg = '#111111', minimap_viewport_bg = '#2a2a2a', minimap_cursor = '#C8C8C8', @@ -47,6 +48,7 @@ local M = { added = '#50A14F', deleted = '#E45649', changed = '#A626A4', + manual = '#7C6AD8', minimap_bg = '#EAEAEB', minimap_viewport_bg = '#DBDBDC', minimap_cursor = '#111111', diff --git a/tests/integration/diffview_spec.lua b/tests/integration/diffview_spec.lua index 7eceef8..35dc69f 100644 --- a/tests/integration/diffview_spec.lua +++ b/tests/integration/diffview_spec.lua @@ -160,7 +160,9 @@ return { A.equal(vim.api.nvim_get_option_value('buftype', { buf = result_buf }), '') A.equal(vim.api.nvim_get_option_value('readonly', { buf = result_buf }), false) A.equal(vim.api.nvim_get_option_value('modifiable', { buf = result_buf }), true) - A.equal(minimap.win, nil) + A.truthy(minimap.win and vim.api.nvim_win_is_valid(minimap.win)) + A.equal(minimap.mode, 'merge') + A.equal(minimap.target_win, result_win) A.same(vim.api.nvim_buf_get_lines(theirs_buf, 0, -1, false), { 'feature' }) A.same(vim.api.nvim_buf_get_lines(ours_buf, 0, -1, false), { 'main' }) A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { 'base' }) @@ -257,16 +259,20 @@ return { local result_win = workspace.get_win(diffview.workspace, 'merge_result') local marks = merge_extmarks(result_buf) local placeholder = '' + local placeholder_above = false for _, mark in ipairs(marks) do if mark[4] and mark[4].virt_lines and mark[4].virt_lines[1] and mark[4].virt_lines[1][1] then placeholder = mark[4].virt_lines[1][1][1] + placeholder_above = mark[4].virt_lines_above == true break end end A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { 'alpha', 'omega' }) A.contains(placeholder, 'unresolved') + A.contains(placeholder, 'insert') + A.truthy(placeholder_above) A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 2) vim.api.nvim_buf_set_lines(result_buf, 1, 1, false, { 'draft insert' }) @@ -327,7 +333,6 @@ return { } N.press('\\o') - N.press('\\T') vim.api.nvim_buf_set_lines(result_buf, 0, -1, false, expected_result) vim.api.nvim_buf_call(result_buf, function() @@ -378,7 +383,6 @@ return { A.equal(vim.api.nvim_get_option_value('endofline', { buf = result_buf }), false) N.press('\\o') - N.press('\\T') vim.api.nvim_set_option_value('endofline', false, { buf = result_buf }) vim.api.nvim_buf_call(result_buf, function() vim.cmd('write') @@ -405,7 +409,6 @@ return { local result_win = workspace.get_win(diffview.workspace, 'merge_result') N.press('\\o') - N.press('\\T') vim.api.nvim_buf_call(result_buf, function() vim.cmd('write') @@ -510,7 +513,70 @@ return { end, }, { - name = 'merge action bar renders configured keys and accept ours persists partial progress', + name = 'merge minimap renders conflict states and active conflict changes', + run = function() + N.with_repo('repo_conflict_multi', function() + require('glance').start() + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local minimap = require('glance.minimap') + local logic = require('glance.minimap_logic') + local workspace = require('glance.workspace') + + ui.open_file(filetree.files.conflicts[1]) + + local result_win = workspace.get_win(diffview.workspace, 'merge_result') + A.truthy(minimap.win and vim.api.nvim_win_is_valid(minimap.win)) + A.equal(minimap.mode, 'merge') + A.equal(minimap.target_win, result_win) + A.contains(minimap.cached_pixels, logic.states.MERGE_ACTIVE) + A.contains(minimap.cached_pixels, logic.states.MERGE_UNRESOLVED) + + N.press('\\o') + N.press(']x') + + A.contains(minimap.cached_pixels, logic.states.MERGE_HANDLED) + A.contains(minimap.cached_pixels, logic.states.MERGE_ACTIVE) + end) + end, + }, + { + name = 'merge minimap renders manual conflict state before explicit resolution', + run = function() + N.with_repo('repo_conflict_multi', function() + require('glance').start() + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local minimap = require('glance.minimap') + local logic = require('glance.minimap_logic') + local workspace = require('glance.workspace') + + ui.open_file(filetree.files.conflicts[1]) + + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + vim.api.nvim_buf_set_lines(result_buf, 1, 2, false, { 'manual first resolution' }) + vim.api.nvim_exec_autocmds('TextChanged', { + buffer = result_buf, + modeline = false, + }) + + N.press(']x') + A.contains(minimap.cached_pixels, logic.states.MERGE_MANUAL) + A.contains(minimap.cached_pixels, logic.states.MERGE_ACTIVE) + + N.press('[x') + N.press('\\m') + N.press(']x') + + A.contains(minimap.cached_pixels, logic.states.MERGE_HANDLED) + A.contains(minimap.cached_pixels, logic.states.MERGE_ACTIVE) + end) + end, + }, + { + name = 'merge action bar renders configured keys and accept ours resolves immediately', run = function() N.with_repo('repo_conflict', function(repo) require('glance').start() @@ -531,29 +597,23 @@ return { A.contains(winbar, '1/1') A.contains(winbar, 'unresolved') A.contains(winbar, '\\o ours') - A.contains(winbar, '\\O skip ours') + A.contains(winbar, '\\t theirs') + A.contains(winbar, '\\O both o/t') + A.contains(winbar, '\\T both t/o') + A.contains(winbar, '\\b base') N.press('\\o') A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { 'main' }) winbar = vim.api.nvim_get_option_value('winbar', { win = result_win }) - A.contains(winbar, 'pending: ours') - A.contains(winbar, '1 unresolved') + A.contains(winbar, 'handled: ours') + A.contains(winbar, '0 unresolved') vim.api.nvim_buf_call(result_buf, function() vim.cmd('write') end) - A.equal(repo:read(repo.files.tracked), table.concat({ - '<<<<<<< Ours', - 'main', - '||||||| Base', - 'main', - '=======', - 'feature', - '>>>>>>> Theirs', - '', - }, '\n')) + A.equal(repo:read(repo.files.tracked), 'main\n') diffview.close(true) ui.open_file(filetree.files.conflicts[1]) @@ -562,8 +622,8 @@ return { local reopened_win = workspace.get_win(diffview.workspace, 'merge_result') A.same(vim.api.nvim_buf_get_lines(reopened_buf, 0, -1, false), { 'main' }) winbar = vim.api.nvim_get_option_value('winbar', { win = reopened_win }) - A.contains(winbar, 'pending: ours') - A.contains(winbar, '1 unresolved') + A.contains(winbar, 'manual resolved') + A.contains(winbar, '0 unresolved') end) end, }, @@ -583,7 +643,6 @@ return { local result_win = workspace.get_win(diffview.workspace, 'merge_result') N.press('\\o') - N.press('\\T') A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { 'main' }) A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '0 unresolved') diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 717636c..3de63ff 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -36,6 +36,7 @@ return { added = '#2ea043', deleted = '#f85149', changed = '#d29922', + manual = '#B48CFF', minimap_bg = '#111111', minimap_viewport_bg = '#2a2a2a', minimap_cursor = '#C8C8C8', @@ -80,10 +81,9 @@ return { keymaps = { accept_ours = 'o', accept_theirs = 't', - accept_both_ours_then_theirs = 'b', - accept_both_theirs_then_ours = 'B', - ignore_ours = 'O', - ignore_theirs = 'T', + accept_both_ours_then_theirs = 'O', + accept_both_theirs_then_ours = 'T', + keep_base = 'b', reset_conflict = 'r', mark_resolved = 'm', }, diff --git a/tests/unit/merge_model_spec.lua b/tests/unit/merge_model_spec.lua index f33b451..4ced79f 100644 --- a/tests/unit/merge_model_spec.lua +++ b/tests/unit/merge_model_spec.lua @@ -165,7 +165,6 @@ return { local previous_model = assert(merge_model.build(file)) assert(merge_model.apply_action(previous_model, 1, 'accept_ours')) - assert(merge_model.apply_action(previous_model, 1, 'ignore_theirs')) local prepared = assert(merge_model.prepare_write(file, { 'intro updated', @@ -226,7 +225,6 @@ return { local previous_model = assert(merge_model.build(file)) assert(merge_model.apply_action(previous_model, 1, 'accept_ours')) - assert(merge_model.apply_action(previous_model, 1, 'ignore_theirs')) local prepared = assert(merge_model.prepare_write(file, { 'main' }, { current_ends_with_newline = false, @@ -264,6 +262,41 @@ return { end) end, }, + { + name = 'legacy partial marker state can be completed by accepting the same side', + run = function() + N.with_repo('repo_conflict', function(repo) + local git = require('glance.git') + local merge_model = require('glance.merge.model') + local file = git.get_changed_files().conflicts[1] + + repo:write(repo.files.tracked, table.concat({ + '<<<<<<< Ours', + 'main', + '||||||| Base', + 'main', + '=======', + 'feature', + '>>>>>>> Theirs', + '', + }, '\n')) + + local legacy = assert(merge_model.build(file)) + A.equal(legacy.conflicts[1].state, 'ours') + A.truthy(legacy.conflicts[1].ours_handled) + A.falsy(legacy.conflicts[1].theirs_handled) + A.falsy(legacy.conflicts[1].handled) + + assert(merge_model.apply_action(legacy, 1, 'accept_ours')) + + local prepared = assert(merge_model.prepare_write(file, { 'main' }, { + previous_model = legacy, + })) + + A.same(prepared.persisted_lines, { 'main' }) + end) + end, + }, { name = 'rebuild preserves explicit in-session states when the clean result text is unchanged', run = function() @@ -272,27 +305,26 @@ return { local merge_model = require('glance.merge.model') local file = git.get_changed_files().conflicts[1] - local pending = assert(merge_model.build(file)) - assert(merge_model.apply_action(pending, 1, 'accept_ours')) + local handled_ours = assert(merge_model.build(file)) + assert(merge_model.apply_action(handled_ours, 1, 'accept_ours')) - local rebuilt_pending = assert(merge_model.build(file, { + local rebuilt_ours = assert(merge_model.build(file, { current_lines = { 'main' }, current_ends_with_newline = true, - previous_model = pending, + previous_model = handled_ours, manual_clean_state = 'manual_unresolved', })) - A.same(snapshot(rebuilt_pending.conflicts[1]), { + A.same(snapshot(rebuilt_ours.conflicts[1]), { state = 'ours', ours_handled = true, - theirs_handled = false, - handled = false, + theirs_handled = true, + handled = true, current_result_lines = { 'main' }, }) local handled_base = assert(merge_model.build(file)) - assert(merge_model.apply_action(handled_base, 1, 'ignore_ours')) - assert(merge_model.apply_action(handled_base, 1, 'ignore_theirs')) + assert(merge_model.apply_action(handled_base, 1, 'keep_base')) local rebuilt_base = assert(merge_model.build(file, { current_lines = { 'base' }, @@ -312,7 +344,7 @@ return { end, }, { - name = 'text-conflict action transitions use replacement semantics instead of additive semantics', + name = 'text-conflict action transitions resolve with replacement semantics', run = function() N.with_repo('repo_conflict', function() local git = require('glance.git') @@ -321,143 +353,35 @@ return { local cases = { { actions = { 'accept_ours' }, - expected = { - state = 'ours', - ours_handled = true, - theirs_handled = false, - handled = false, - current_result_lines = { 'main' }, - }, + expected = expected_snapshot('ours', true, true, { 'main' }), }, { actions = { 'accept_theirs' }, - expected = { - state = 'theirs', - ours_handled = false, - theirs_handled = true, - handled = false, - current_result_lines = { 'feature' }, - }, - }, - { - actions = { 'ignore_ours' }, - expected = { - state = 'base_only', - ours_handled = true, - theirs_handled = false, - handled = false, - current_result_lines = { 'base' }, - }, + expected = expected_snapshot('theirs', true, true, { 'feature' }), }, { - actions = { 'ignore_theirs' }, - expected = { - state = 'base_only', - ours_handled = false, - theirs_handled = true, - handled = false, - current_result_lines = { 'base' }, - }, + actions = { 'keep_base' }, + expected = expected_snapshot('base_only', true, true, { 'base' }), }, { actions = { 'accept_theirs', 'accept_ours' }, - expected = { - state = 'ours', - ours_handled = true, - theirs_handled = true, - handled = true, - current_result_lines = { 'main' }, - }, + expected = expected_snapshot('ours', true, true, { 'main' }), }, { actions = { 'accept_ours', 'accept_theirs' }, - expected = { - state = 'theirs', - ours_handled = true, - theirs_handled = true, - handled = true, - current_result_lines = { 'feature' }, - }, - }, - { - actions = { 'ignore_theirs', 'accept_ours' }, - expected = { - state = 'ours', - ours_handled = true, - theirs_handled = true, - handled = true, - current_result_lines = { 'main' }, - }, - }, - { - actions = { 'ignore_ours', 'accept_theirs' }, - expected = { - state = 'theirs', - ours_handled = true, - theirs_handled = true, - handled = true, - current_result_lines = { 'feature' }, - }, - }, - { - actions = { 'accept_ours', 'ignore_theirs' }, - expected = { - state = 'ours', - ours_handled = true, - theirs_handled = true, - handled = true, - current_result_lines = { 'main' }, - }, - }, - { - actions = { 'accept_theirs', 'ignore_ours' }, - expected = { - state = 'theirs', - ours_handled = true, - theirs_handled = true, - handled = true, - current_result_lines = { 'feature' }, - }, - }, - { - actions = { 'ignore_theirs', 'ignore_ours' }, - expected = { - state = 'base_only', - ours_handled = true, - theirs_handled = true, - handled = true, - current_result_lines = { 'base' }, - }, + expected = expected_snapshot('theirs', true, true, { 'feature' }), }, { actions = { 'accept_both_ours_then_theirs', 'accept_ours' }, - expected = { - state = 'ours', - ours_handled = true, - theirs_handled = true, - handled = true, - current_result_lines = { 'main' }, - }, + expected = expected_snapshot('ours', true, true, { 'main' }), }, { - actions = { 'accept_both_theirs_then_ours', 'accept_theirs' }, - expected = { - state = 'theirs', - ours_handled = true, - theirs_handled = true, - handled = true, - current_result_lines = { 'feature' }, - }, + actions = { 'keep_base', 'accept_theirs' }, + expected = expected_snapshot('theirs', true, true, { 'feature' }), }, { actions = { 'accept_ours', 'reset_conflict' }, - expected = { - state = 'unresolved', - ours_handled = false, - theirs_handled = false, - handled = false, - current_result_lines = { 'base' }, - }, + expected = expected_snapshot('unresolved', false, false, { 'base' }), }, } @@ -488,14 +412,9 @@ return { manual = { 'custom merge draft' }, } local invalid_mark_resolved = 'mark resolved is only available for manual unresolved conflicts' - local invalid_manual_ignore = 'ignore actions are not available for manual merge states' local expected = { unresolved = expected_snapshot('unresolved', false, false, lines.base), - pending_ours = expected_snapshot('ours', true, false, lines.ours), - pending_theirs = expected_snapshot('theirs', false, true, lines.theirs), - pending_base_from_ignore_ours = expected_snapshot('base_only', true, false, lines.base), - pending_base_from_ignore_theirs = expected_snapshot('base_only', false, true, lines.base), handled_ours = expected_snapshot('ours', true, true, lines.ours), handled_theirs = expected_snapshot('theirs', true, true, lines.theirs), handled_both_ours_then_theirs = @@ -511,6 +430,12 @@ return { return assert(merge_model.build(file)) end + local function build_action_variant(action) + local model = build_base_model() + assert(merge_model.apply_action(model, 1, action)) + return model + end + local function build_manual_variant(mark_resolved) local previous_model = build_base_model() local manual_model = assert(merge_model.build(file, { @@ -526,218 +451,32 @@ return { end local variants = { - { - name = 'unresolved', - model = build_base_model(), - }, - { - name = 'pending_ours', - model = (function() - local model = build_base_model() - assert(merge_model.apply_action(model, 1, 'accept_ours')) - return model - end)(), - }, - { - name = 'pending_theirs', - model = (function() - local model = build_base_model() - assert(merge_model.apply_action(model, 1, 'accept_theirs')) - return model - end)(), - }, - { - name = 'pending_base_from_ignore_ours', - model = (function() - local model = build_base_model() - assert(merge_model.apply_action(model, 1, 'ignore_ours')) - return model - end)(), - }, - { - name = 'pending_base_from_ignore_theirs', - model = (function() - local model = build_base_model() - assert(merge_model.apply_action(model, 1, 'ignore_theirs')) - return model - end)(), - }, - { - name = 'handled_ours', - model = (function() - local model = build_base_model() - assert(merge_model.apply_action(model, 1, 'accept_theirs')) - assert(merge_model.apply_action(model, 1, 'accept_ours')) - return model - end)(), - }, - { - name = 'handled_theirs', - model = (function() - local model = build_base_model() - assert(merge_model.apply_action(model, 1, 'accept_ours')) - assert(merge_model.apply_action(model, 1, 'accept_theirs')) - return model - end)(), - }, - { - name = 'handled_both_ours_then_theirs', - model = (function() - local model = build_base_model() - assert(merge_model.apply_action(model, 1, 'accept_both_ours_then_theirs')) - return model - end)(), - }, - { - name = 'handled_both_theirs_then_ours', - model = (function() - local model = build_base_model() - assert(merge_model.apply_action(model, 1, 'accept_both_theirs_then_ours')) - return model - end)(), - }, - { - name = 'handled_base', - model = (function() - local model = build_base_model() - assert(merge_model.apply_action(model, 1, 'ignore_ours')) - assert(merge_model.apply_action(model, 1, 'ignore_theirs')) - return model - end)(), - }, - { - name = 'manual_unresolved', - model = build_manual_variant(false), - }, - { - name = 'manual_resolved', - model = build_manual_variant(true), - }, + { name = 'unresolved', model = build_base_model() }, + { name = 'handled_ours', model = build_action_variant('accept_ours') }, + { name = 'handled_theirs', model = build_action_variant('accept_theirs') }, + { name = 'handled_both_ours_then_theirs', model = build_action_variant('accept_both_ours_then_theirs') }, + { name = 'handled_both_theirs_then_ours', model = build_action_variant('accept_both_theirs_then_ours') }, + { name = 'handled_base', model = build_action_variant('keep_base') }, + { name = 'manual_unresolved', model = build_manual_variant(false) }, + { name = 'manual_resolved', model = build_manual_variant(true) }, } - local transitions = { - unresolved = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.pending_ours, - accept_theirs = expected.pending_theirs, - ignore_ours = expected.pending_base_from_ignore_ours, - ignore_theirs = expected.pending_base_from_ignore_theirs, - }, - pending_ours = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.pending_ours, - accept_theirs = expected.handled_theirs, - ignore_ours = expected.pending_ours, - ignore_theirs = expected.handled_ours, - }, - pending_theirs = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.handled_ours, - accept_theirs = expected.pending_theirs, - ignore_ours = expected.handled_theirs, - ignore_theirs = expected.pending_theirs, - }, - pending_base_from_ignore_ours = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.pending_ours, - accept_theirs = expected.handled_theirs, - ignore_ours = expected.pending_base_from_ignore_ours, - ignore_theirs = expected.handled_base, - }, - pending_base_from_ignore_theirs = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.handled_ours, - accept_theirs = expected.pending_theirs, - ignore_ours = expected.handled_base, - ignore_theirs = expected.pending_base_from_ignore_theirs, - }, - handled_ours = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.handled_ours, - accept_theirs = expected.handled_theirs, - ignore_ours = expected.handled_ours, - ignore_theirs = expected.handled_ours, - }, - handled_theirs = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.handled_ours, - accept_theirs = expected.handled_theirs, - ignore_ours = expected.handled_theirs, - ignore_theirs = expected.handled_theirs, - }, - handled_both_ours_then_theirs = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.handled_ours, - accept_theirs = expected.handled_theirs, - ignore_ours = expected.handled_both_ours_then_theirs, - ignore_theirs = expected.handled_both_ours_then_theirs, - }, - handled_both_theirs_then_ours = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.handled_ours, - accept_theirs = expected.handled_theirs, - ignore_ours = expected.handled_both_theirs_then_ours, - ignore_theirs = expected.handled_both_theirs_then_ours, - }, - handled_base = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.handled_ours, - accept_theirs = expected.handled_theirs, - ignore_ours = expected.handled_base, - ignore_theirs = expected.handled_base, - }, - manual_unresolved = { - mark_resolved = expected.manual_resolved, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.pending_ours, - accept_theirs = expected.pending_theirs, - ignore_ours = { err = invalid_manual_ignore }, - ignore_theirs = { err = invalid_manual_ignore }, - }, - manual_resolved = { - mark_resolved = { err = invalid_mark_resolved }, - reset_conflict = expected.unresolved, - accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, - accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, - accept_ours = expected.handled_ours, - accept_theirs = expected.handled_theirs, - ignore_ours = { err = invalid_manual_ignore }, - ignore_theirs = { err = invalid_manual_ignore }, - }, + local replace_transitions = { + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.handled_ours, + accept_theirs = expected.handled_theirs, + keep_base = expected.handled_base, } + local transitions = {} + for _, variant in ipairs(variants) do + transitions[variant.name] = vim.deepcopy(replace_transitions) + transitions[variant.name].mark_resolved = { err = invalid_mark_resolved } + end + transitions.manual_unresolved.mark_resolved = expected.manual_resolved + local actions = { 'mark_resolved', 'reset_conflict', @@ -745,8 +484,7 @@ return { 'accept_both_theirs_then_ours', 'accept_ours', 'accept_theirs', - 'ignore_ours', - 'ignore_theirs', + 'keep_base', } local exercised = 0 @@ -773,7 +511,7 @@ return { end, }, { - name = 'accept ours persists and rehydrates as a partial unresolved state', + name = 'accept ours persists clean text and reopens as manual_resolved', run = function() N.with_repo('repo_conflict', function(repo) local git = require('glance.git') @@ -784,31 +522,23 @@ return { local updated = assert(merge_model.apply_action(previous_model, 1, 'accept_ours')) A.equal(updated.state, 'ours') A.truthy(updated.ours_handled) - A.falsy(updated.theirs_handled) - A.falsy(updated.handled) + A.truthy(updated.theirs_handled) + A.truthy(updated.handled) local prepared = assert(merge_model.prepare_write(file, { 'main' }, { previous_model = previous_model, })) - A.same(prepared.persisted_lines, { - '<<<<<<< Ours', - 'main', - '||||||| Base', - 'main', - '=======', - 'feature', - '>>>>>>> Theirs', - }) + A.equal(prepared.model.conflicts[1].state, 'ours') + A.truthy(prepared.model.conflicts[1].handled) + A.same(prepared.persisted_lines, { 'main' }) repo:write(repo.files.tracked, prepared.persisted_text) local reopened = assert(merge_model.build(file)) - A.equal(reopened.conflicts[1].state, 'ours') - A.truthy(reopened.conflicts[1].ours_handled) - A.falsy(reopened.conflicts[1].theirs_handled) - A.falsy(reopened.conflicts[1].handled) - A.equal(reopened.unresolved_count, 1) + A.equal(reopened.conflicts[1].state, 'manual_resolved') + A.truthy(reopened.conflicts[1].handled) + A.equal(reopened.unresolved_count, 0) A.same(reopened.result_lines, { 'main' }) end) end, @@ -854,7 +584,7 @@ return { end, }, { - name = 'accept ours keeps add/add conflicts visible and serializable with an empty base', + name = 'accept ours resolves add/add conflicts with an empty base', run = function() N.with_repo('repo_conflict_add_add', function(repo) local git = require('glance.git') @@ -868,23 +598,15 @@ return { previous_model = previous_model, })) - A.same(prepared.persisted_lines, { - '<<<<<<< Ours', - 'main add', - '||||||| Base', - 'main add', - '=======', - 'feature add', - '>>>>>>> Theirs', - }) + A.same(prepared.persisted_lines, { 'main add' }) repo:write(repo.files.tracked, prepared.persisted_text) local reopened = assert(merge_model.build(file)) - A.equal(reopened.conflicts[1].state, 'ours') + A.equal(reopened.conflicts[1].state, 'manual_resolved') A.equal(reopened.conflicts[1].result_range.start, 1) A.equal(reopened.conflicts[1].result_range.count, 1) - A.equal(reopened.unresolved_count, 1) + A.equal(reopened.unresolved_count, 0) A.same(reopened.result_lines, { 'main add' }) end) end, diff --git a/tests/unit/minimap_logic_spec.lua b/tests/unit/minimap_logic_spec.lua index f274c19..6a1f35a 100644 --- a/tests/unit/minimap_logic_spec.lua +++ b/tests/unit/minimap_logic_spec.lua @@ -58,5 +58,50 @@ return { A.equal(logic.cursor_pixel(10, 10, 0), nil) end, }, + { + name = 'compute merge line types maps conflict states and zero-line ranges', + run = function() + local logic = require('glance.minimap_logic') + local line_types = logic.compute_merge_line_types({ + { + state = 'unresolved', + handled = false, + result_range = { start = 2, count = 0 }, + }, + { + state = 'ours', + handled = true, + result_range = { start = 5, count = 2 }, + }, + { + state = 'manual_unresolved', + handled = false, + result_range = { start = 10, count = 1 }, + }, + { + state = 'manual_resolved', + handled = true, + result_range = { start = 12, count = 1 }, + }, + }, 12) + + A.equal(line_types[2], logic.states.MERGE_UNRESOLVED) + A.equal(line_types[5], logic.states.MERGE_HANDLED) + A.equal(line_types[6], logic.states.MERGE_HANDLED) + A.equal(line_types[10], logic.states.MERGE_MANUAL) + A.equal(line_types[12], logic.states.MERGE_HANDLED) + + local active_types = logic.compute_merge_line_types({ + { + state = 'unresolved', + handled = false, + result_range = { start = 3, count = 2 }, + }, + }, 8, 1) + + A.equal(active_types[3], logic.states.MERGE_ACTIVE) + A.equal(active_types[4], logic.states.MERGE_ACTIVE) + end, + }, }, }