diff --git a/lua/glance/config.lua b/lua/glance/config.lua index d6ba4ce..2dd55f4 100644 --- a/lua/glance/config.lua +++ b/lua/glance/config.lua @@ -14,6 +14,7 @@ local ALLOWED_TOP_LEVEL = { windows = true, filetree = true, log = true, + merge = true, keymaps = true, pane_navigation = true, hunk_navigation = true, @@ -85,6 +86,32 @@ local ALLOWED_LOG = { max_commits = true, } +local ALLOWED_MERGE = { + keymaps = true, +} + +local ALLOWED_MERGE_KEYMAPS = { + accept_ours = true, + accept_theirs = true, + accept_both_ours_then_theirs = true, + accept_both_theirs_then_ours = true, + ignore_ours = true, + ignore_theirs = true, + reset_conflict = true, + mark_resolved = true, +} + +local MERGE_KEYMAP_ORDER = { + 'accept_ours', + 'accept_theirs', + 'accept_both_ours_then_theirs', + 'accept_both_theirs_then_ours', + 'ignore_ours', + 'ignore_theirs', + 'reset_conflict', + 'mark_resolved', +} + local ALLOWED_FILETREE_WINDOW = { width = true, number = true, @@ -222,6 +249,18 @@ local BASE_DEFAULTS = { log = { max_commits = 200, }, + merge = { + 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', + reset_conflict = 'r', + mark_resolved = 'm', + }, + }, keymaps = { open_file = '', quit = 'q', @@ -403,6 +442,44 @@ local function validate_log(options) validate_integer(log.max_commits, 'log.max_commits', 1) end +local function validate_merge(options) + local merge = options.merge + local keymaps = merge.keymaps or {} + local seen = {} + + validate_known_keys(merge, ALLOWED_MERGE, 'merge') + validate_known_keys(keymaps, ALLOWED_MERGE_KEYMAPS, 'merge.keymaps') + + for _, key in ipairs(MERGE_KEYMAP_ORDER) do + local value = keymaps[key] + validate_string(value, 'merge.keymaps.' .. key) + if seen[value] then + fail('merge.keymaps.' .. key .. ' conflicts with merge.keymaps.' .. seen[value]) + end + seen[value] = key + end + + for name, lhs in pairs(keymaps) do + for key, value in pairs(options.keymaps or {}) do + if value == lhs then + fail('merge.keymaps.' .. name .. ' conflicts with keymaps.' .. key) + end + end + + for key, value in pairs(options.pane_navigation or {}) do + if value == lhs then + fail('merge.keymaps.' .. name .. ' conflicts with pane_navigation.' .. key) + end + end + + for key, value in pairs(options.hunk_navigation or {}) do + if value == lhs then + fail('merge.keymaps.' .. name .. ' conflicts with hunk_navigation.' .. key) + end + end + end +end + local function validate_keymaps(options) local keymaps = options.keymaps local seen = {} @@ -527,6 +604,7 @@ local function validate_options(options) validate_windows(options) validate_filetree(options) validate_log(options) + validate_merge(options) validate_keymaps(options) validate_pane_navigation(options) validate_hunk_navigation(options) diff --git a/lua/glance/init.lua b/lua/glance/init.lua index b10a267..090f60a 100644 --- a/lua/glance/init.lua +++ b/lua/glance/init.lua @@ -115,6 +115,33 @@ function M.start() end end +local function hex_to_rgb(hex) + if type(hex) ~= 'string' then + return nil + end + + local value = hex:gsub('#', '') + if #value ~= 6 then + return nil + end + + return tonumber(value:sub(1, 2), 16), tonumber(value:sub(3, 4), 16), tonumber(value:sub(5, 6), 16) +end + +local function blend_hex(base_hex, tint_hex, alpha) + local base_r, base_g, base_b = hex_to_rgb(base_hex) + local tint_r, tint_g, tint_b = hex_to_rgb(tint_hex) + if not base_r or not tint_r then + return base_hex + end + + local function mix(base, tint) + return math.floor((base * (1 - alpha)) + (tint * alpha) + 0.5) + end + + return string.format('#%02X%02X%02X', mix(base_r, tint_r), mix(base_g, tint_g), mix(base_b, tint_b)) +end + function M.setup_highlights() local palette = require('glance.config').options.theme.palette local bg = palette.bg @@ -128,6 +155,9 @@ function M.setup_highlights() 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_handled_bg = blend_hex(bg, palette.added, 0.16) + local merge_manual_bg = blend_hex(bg, type_color, 0.14) -- Editor vim.api.nvim_set_hl(0, 'Normal', { bg = bg, fg = fg }) @@ -227,6 +257,13 @@ 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, '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, '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 }) + vim.api.nvim_set_hl(0, 'GlanceConflictActiveNumber', { fg = palette.split_hover, bg = bg, bold = true }) -- Minimap highlights require('glance.minimap').setup_highlights() diff --git a/lua/glance/merge/actions.lua b/lua/glance/merge/actions.lua new file mode 100644 index 0000000..23876b5 --- /dev/null +++ b/lua/glance/merge/actions.lua @@ -0,0 +1,67 @@ +local model = require('glance.merge.model') + +local M = {} + +local DEFINITIONS = { + { id = 'accept_ours', short = 'ours' }, + { 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 = 'reset_conflict', short = 'reset' }, + { id = 'mark_resolved', short = 'resolve' }, +} + +local function display_key(lhs) + if type(lhs) ~= 'string' then + return '' + end + + return lhs:gsub('', '\\'):gsub('', '\\') +end + +function M.available(conflict) + local actions = {} + if type(conflict) ~= 'table' then + return actions + end + + for _, definition in ipairs(DEFINITIONS) do + local id = definition.id + if id == 'mark_resolved' then + 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 + end + + return actions +end + +function M.hint_text(conflict, keymaps) + local parts = {} + for _, definition in ipairs(M.available(conflict)) do + local lhs = keymaps and keymaps[definition.id] or nil + local key = display_key(lhs) + if key ~= '' then + parts[#parts + 1] = string.format('%s %s', key, definition.short) + else + parts[#parts + 1] = definition.short + end + end + + return table.concat(parts, ' | ') +end + +function M.apply(merge_model, index, action) + return model.apply_action(merge_model, index, action) +end + +return M diff --git a/lua/glance/merge/init.lua b/lua/glance/merge/init.lua index 7a9f599..0719b90 100644 --- a/lua/glance/merge/init.lua +++ b/lua/glance/merge/init.lua @@ -1,3 +1,4 @@ +local actions = require('glance.merge.actions') local config = require('glance.config') local filetree = require('glance.filetree') local git = require('glance.git') @@ -14,6 +15,7 @@ local state = { model = nil, active_conflict_index = nil, write_in_progress = false, + sync_in_progress = false, } local function panes(diffview) @@ -33,6 +35,14 @@ local function panes(diffview) } end +local function refresh_decorations(diffview) + if not state.model then + return + end + + render.decorate(panes(diffview), state.model, state.active_conflict_index) +end + local function result_win(diffview) local win = workspace.get_win(diffview.workspace, layout.RESULT_ROLE) if win and vim.api.nvim_win_is_valid(win) then @@ -82,6 +92,49 @@ local function first_unresolved_index() return unresolved[1] end +local function conflict_index_for_result_line(line) + if not state.model then + return nil + end + + for index, conflict in ipairs(state.model.conflicts) do + local start = conflict.result_range.start + local count = conflict.result_range.count + if count == 0 then + if line == start then + return index + end + elseif line >= start and line < (start + count) then + return index + end + end + + return nil +end + +local function update_active_conflict_from_cursor(diffview) + local win = result_win(diffview) + if not win or vim.api.nvim_get_current_win() ~= win then + return state.active_conflict_index + end + + local index = conflict_index_for_result_line(vim.api.nvim_win_get_cursor(win)[1]) + if index then + state.active_conflict_index = index + end + + return state.active_conflict_index +end + +local function current_conflict_index(diffview) + local index = update_active_conflict_from_cursor(diffview) + if index and state.model and state.model.conflicts[index] then + return index + end + + return state.active_conflict_index or first_unresolved_index() or 1 +end + local function focus_result(diffview) local win = result_win(diffview) if not win then @@ -174,6 +227,69 @@ local function bind_write_command(diffview, file) }) end +local function sync_from_result_buffer(diffview, file, previous_model) + local buf = result_buf(diffview) + if not buf then + return nil, 'merge result buffer is not available' + end + + local merge_model, err = model.build(file, { + current_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false), + current_ends_with_newline = vim.api.nvim_get_option_value('endofline', { buf = buf }), + previous_model = previous_model or state.model, + manual_clean_state = 'manual_unresolved', + }) + if not merge_model then + return nil, err + end + + state.file = file + state.model = merge_model + render.apply(diffview, panes(diffview), merge_model, file, { + refresh_sources = false, + refresh_result = false, + active_conflict_index = state.active_conflict_index, + }) + update_active_conflict_from_cursor(diffview) + refresh_decorations(diffview) + return merge_model +end + +local function apply_action(diffview, action) + local buf = result_buf(diffview) + if not buf then + return false + end + + local index = current_conflict_index(diffview) + local conflict = state.model and state.model.conflicts[index] or nil + if not conflict then + return false + end + + local start_line = conflict.result_range.start - 1 + local stop_line = conflict.result_range.start + conflict.result_range.count - 1 + local previous_model = state.model + local updated, err = actions.apply(previous_model, index, action) + if not updated then + vim.notify('glance: failed to apply merge action: ' .. err, vim.log.levels.WARN) + return false + end + + state.sync_in_progress = true + vim.api.nvim_buf_set_lines(buf, start_line, stop_line, false, updated.current_result_lines or updated.current_lines or {}) + state.sync_in_progress = false + + local merge_model, sync_err = sync_from_result_buffer(diffview, state.file, previous_model) + if not merge_model then + vim.notify('glance: failed to refresh merge state: ' .. sync_err, vim.log.levels.WARN) + return false + end + + M.jump_to_conflict(diffview, index) + return true +end + local function bind_navigation_keymaps(diffview) for _, role in ipairs({ layout.THEIRS_ROLE, layout.OURS_ROLE, layout.RESULT_ROLE }) do local buf = workspace.get_buf(diffview.workspace, role) @@ -194,6 +310,58 @@ local function bind_navigation_keymaps(diffview) end end +local function bind_action_keymaps(diffview) + local keymaps = config.options.merge.keymaps or {} + for _, role in ipairs({ layout.THEIRS_ROLE, layout.OURS_ROLE, layout.RESULT_ROLE }) do + local buf = workspace.get_buf(diffview.workspace, role) + if buf and vim.api.nvim_buf_is_valid(buf) then + for action, lhs in pairs(keymaps) do + vim.keymap.set('n', lhs, function() + apply_action(diffview, action) + end, { + buffer = buf, + silent = true, + }) + end + end + end +end + +local function bind_result_tracking(diffview, file) + local buf = result_buf(diffview) + if not buf then + return + end + + vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { + group = diffview.autocmd_group, + buffer = buf, + callback = function() + if state.write_in_progress or state.sync_in_progress then + return + end + + local previous_model = state.model + local merge_model = sync_from_result_buffer(diffview, file, previous_model) + if not merge_model then + state.model = previous_model + end + end, + }) + + vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, { + group = diffview.autocmd_group, + buffer = buf, + callback = function() + local previous = state.active_conflict_index + local current = update_active_conflict_from_cursor(diffview) + if current ~= previous then + refresh_decorations(diffview) + end + end, + }) +end + function M.is_active() return state.active == true end @@ -219,6 +387,7 @@ function M.jump_to_conflict(diffview, index) vim.api.nvim_set_current_win(win) vim.api.nvim_win_set_cursor(win, { math.max(line, 1), 0 }) state.active_conflict_index = index + refresh_decorations(diffview) return true end @@ -291,7 +460,9 @@ local function rebuild(diffview, file) state.file = file state.model = merge_model - render.apply(diffview, panes(diffview), merge_model, file) + render.apply(diffview, panes(diffview), merge_model, file, { + active_conflict_index = state.active_conflict_index, + }) layout.equalize(diffview) return merge_model end @@ -329,6 +500,8 @@ function M.open(diffview, file) bind_write_command(diffview, file) diffview.bind_buffer_keymaps() bind_navigation_keymaps(diffview) + bind_action_keymaps(diffview) + bind_result_tracking(diffview, file) local first = first_unresolved_index() or 1 if not M.jump_to_conflict(diffview, first) then @@ -376,6 +549,7 @@ function M.reset() state.model = nil state.active_conflict_index = nil state.write_in_progress = false + state.sync_in_progress = false end return M diff --git a/lua/glance/merge/model.lua b/lua/glance/merge/model.lua index e286822..39d6588 100644 --- a/lua/glance/merge/model.lua +++ b/lua/glance/merge/model.lua @@ -300,6 +300,17 @@ local function assign_source_ranges(conflicts, base_lines, ours_lines, theirs_li end local function clean_candidates(conflict) + local candidates = conflict.candidates + if candidates then + return { + { state = 'ours', lines = candidates.ours }, + { state = 'theirs', lines = candidates.theirs }, + { state = 'both_ours_then_theirs', lines = candidates.both_ours_then_theirs }, + { state = 'both_theirs_then_ours', lines = candidates.both_theirs_then_ours }, + { state = 'base_only', lines = candidates.base_only }, + } + end + return { { state = 'ours', lines = conflict.ours_lines }, { state = 'theirs', lines = conflict.theirs_lines }, @@ -309,62 +320,182 @@ local function clean_candidates(conflict) } end -local function collect_outcomes_strict(conflict, current_lines, cursor, next_stable, is_last) +local function candidate_for_state(conflict, state) + for _, candidate in ipairs(clean_candidates(conflict)) do + if candidate.state == state then + return candidate + end + end + + return nil +end + +local function candidate_for_lines(conflict, lines) + for _, candidate in ipairs(clean_candidates(conflict)) do + if same_lines(candidate.lines, lines) then + return candidate + end + end + + return nil +end + +local function current_result_lines_for(conflict, state, fallback) + if state == 'unresolved' then + return conflict.base_lines + end + + local candidate = candidate_for_state(conflict, state) + if candidate then + return candidate.lines + end + + return fallback or conflict.current_lines or {} +end + +local function manual_clean_state(opts) + if type(opts) == 'table' and opts.manual_clean_state then + return opts.manual_clean_state + end + + return 'manual_resolved' +end + +local function exact_marker_outcome(conflict, block) + if same_lines(block.ours_lines, conflict.ours_lines) + and same_lines(block.theirs_lines, conflict.theirs_lines) + and (#block.base_lines == 0 or same_lines(block.base_lines, conflict.base_lines)) + then + return { + state = 'unresolved', + current_lines = block.full_lines, + kind = 'marker', + display_lines = conflict.base_lines, + ours_handled = false, + theirs_handled = false, + } + end + + local candidate = candidate_for_lines(conflict, block.base_lines) + if not candidate then + return nil + end + + local ours_handled = same_lines(block.ours_lines, candidate.lines) + local theirs_handled = same_lines(block.theirs_lines, candidate.lines) + + if not ours_handled and not same_lines(block.ours_lines, conflict.ours_lines) then + return nil + end + if not theirs_handled and not same_lines(block.theirs_lines, conflict.theirs_lines) then + return nil + end + + local state = candidate.state + local display_lines = candidate.lines + if candidate.state == 'base_only' and not ours_handled and not theirs_handled then + state = 'unresolved' + display_lines = conflict.base_lines + end + + return { + state = state, + current_lines = block.full_lines, + kind = 'marker', + display_lines = display_lines, + ours_handled = ours_handled, + theirs_handled = theirs_handled, + } +end + +local function manual_outcome(conflict, state, lines) + local resolved_state = state + local kind = 'manual' + local display_lines = lines + + if contains_conflict_markers(lines) then + resolved_state = state == 'manual_unresolved' and 'manual_unresolved' or 'unresolved' + kind = 'marker' + display_lines = conflict.base_lines + end + + return { + state = resolved_state, + current_lines = lines, + kind = kind, + display_lines = display_lines, + ours_handled = resolved_state == 'manual_resolved', + theirs_handled = resolved_state == 'manual_resolved', + } +end + +local function outcome_key(outcome, next_cursor) + return table.concat({ + outcome.state, + outcome.kind, + tostring(next_cursor), + tostring(#(outcome.current_lines or {})), + tostring(#(outcome.display_lines or {})), + tostring(outcome.ours_handled == true), + tostring(outcome.theirs_handled == true), + }, ':') +end + +local function collect_outcomes_strict(conflict, current_lines, cursor, next_stable, is_last, opts) local outcomes = {} local seen = {} - local function add_outcome(state, current_segment, next_cursor, kind) - local key = table.concat({ - state, - tostring(next_cursor), - tostring(#current_segment), - kind, - }, ':') + local function add_outcome(outcome, next_cursor) + local key = outcome_key(outcome, next_cursor) if seen[key] then return end seen[key] = true - outcomes[#outcomes + 1] = { - state = state, - current_lines = current_segment, - next_cursor = next_cursor, - kind = kind, - } + outcome.next_cursor = next_cursor + outcomes[#outcomes + 1] = outcome end local block, block_next = parse_conflict_block(current_lines, cursor) if block then - if same_lines(block.ours_lines, conflict.ours_lines) - and same_lines(block.theirs_lines, conflict.theirs_lines) - and (#block.base_lines == 0 or same_lines(block.base_lines, conflict.base_lines)) - then - add_outcome('unresolved', block.full_lines, block_next, 'marker') - else - add_outcome('manual_unresolved', block.full_lines, block_next, 'marker') - end + add_outcome(exact_marker_outcome(conflict, block) or { + state = 'manual_unresolved', + current_lines = block.full_lines, + kind = 'marker', + display_lines = conflict.base_lines, + ours_handled = false, + theirs_handled = false, + }, block_next) end for _, candidate in ipairs(clean_candidates(conflict)) do if same_lines_at(current_lines, cursor, candidate.lines) then - add_outcome(candidate.state, candidate.lines, cursor + #candidate.lines, 'clean') + add_outcome({ + state = candidate.state, + current_lines = candidate.lines, + kind = 'clean', + display_lines = candidate.lines, + ours_handled = true, + theirs_handled = true, + }, cursor + #candidate.lines) end end + local manual_state = manual_clean_state(opts) if is_last then if #next_stable == 0 then - add_outcome('manual_unresolved', slice_lines(current_lines, cursor, #current_lines), #current_lines + 1, 'manual') + add_outcome(manual_outcome(conflict, manual_state, slice_lines(current_lines, cursor, #current_lines)), #current_lines + 1) else for _, position in ipairs(find_sequence_positions(current_lines, next_stable, cursor)) do if position + #next_stable - 1 == #current_lines then - add_outcome('manual_unresolved', slice_lines(current_lines, cursor, position - 1), position, 'manual') + add_outcome(manual_outcome(conflict, manual_state, slice_lines(current_lines, cursor, position - 1)), position) end end end elseif #next_stable > 0 then for _, position in ipairs(find_sequence_positions(current_lines, next_stable, cursor)) do if position > cursor then - add_outcome('manual_unresolved', slice_lines(current_lines, cursor, position - 1), position, 'manual') + add_outcome(manual_outcome(conflict, manual_state, slice_lines(current_lines, cursor, position - 1)), position) end end end @@ -372,7 +503,7 @@ local function collect_outcomes_strict(conflict, current_lines, cursor, next_sta return outcomes end -local function infer_conflict_states_strict(stable_segments, conflicts, current_lines) +local function infer_conflict_states_strict(stable_segments, conflicts, current_lines, opts) local memo = {} local function solve(index, cursor) @@ -399,7 +530,7 @@ local function infer_conflict_states_strict(stable_segments, conflicts, current_ local after_before = cursor + #before local next_stable = stable_segments[index + 1] or {} - local outcomes = collect_outcomes_strict(conflicts[index], current_lines, after_before, next_stable, index == #conflicts) + local outcomes = collect_outcomes_strict(conflicts[index], current_lines, after_before, next_stable, index == #conflicts, opts) for _, outcome in ipairs(outcomes) do local tail = solve(index + 1, outcome.next_cursor) @@ -460,6 +591,8 @@ local function occurrence_key(occurrence) occurrence.kind, tostring(occurrence.start), tostring(occurrence.stop), + tostring(occurrence.ours_handled == true), + tostring(occurrence.theirs_handled == true), }, ':') end @@ -481,18 +614,23 @@ local function collect_relaxed_occurrences(conflict, current_lines, cursor, mark if current_lines[index]:match('^<<<<<<<') then local block, next_index = parse_conflict_block(current_lines, index) if block then - local state = 'manual_unresolved' - if same_lines(block.ours_lines, conflict.ours_lines) - and same_lines(block.theirs_lines, conflict.theirs_lines) - and (#block.base_lines == 0 or same_lines(block.base_lines, conflict.base_lines)) - then - state = 'unresolved' - end + local outcome = exact_marker_outcome(conflict, block) + or { + state = 'manual_unresolved', + current_lines = block.full_lines, + kind = 'marker', + display_lines = conflict.base_lines, + ours_handled = false, + theirs_handled = false, + } add_occurrence({ - state = state, - current_lines = block.full_lines, - kind = 'marker', + state = outcome.state, + current_lines = outcome.current_lines, + kind = outcome.kind, + display_lines = outcome.display_lines, + ours_handled = outcome.ours_handled, + theirs_handled = outcome.theirs_handled, start = index, stop = next_index - 1, }) @@ -507,6 +645,9 @@ local function collect_relaxed_occurrences(conflict, current_lines, cursor, mark state = candidate.state, current_lines = candidate.lines, kind = 'clean', + display_lines = candidate.lines, + ours_handled = true, + theirs_handled = true, start = position, stop = position - 1, }) @@ -518,6 +659,9 @@ local function collect_relaxed_occurrences(conflict, current_lines, cursor, mark state = candidate.state, current_lines = candidate.lines, kind = 'clean', + display_lines = candidate.lines, + ours_handled = true, + theirs_handled = true, start = position, stop = position + #candidate.lines - 1, }) @@ -583,6 +727,9 @@ local function infer_conflict_states_relaxed(canonical_stable_segments, conflict current_lines = occurrence.current_lines, next_cursor = occurrence.stop + 1, kind = occurrence.kind, + display_lines = occurrence.display_lines, + ours_handled = occurrence.ours_handled, + theirs_handled = occurrence.theirs_handled, }, } for _, item in ipairs(tail.outcomes) do @@ -616,19 +763,18 @@ local function infer_conflict_states_relaxed(canonical_stable_segments, conflict return resolved.stable_segments, resolved.outcomes end -local function display_lines_for(conflict) - if conflict.state == 'unresolved' then - return conflict.base_lines - end - - if conflict.state == 'manual_unresolved' then - if conflict.current_kind == 'marker' or contains_conflict_markers(conflict.current_lines) then - return conflict.base_lines +local function stable_segments_contain_conflict_markers(stable_segments) + for _, segment in ipairs(stable_segments or {}) do + if contains_conflict_markers(segment) then + return true end - return conflict.current_lines end - return conflict.current_lines + return false +end + +local function display_lines_for(conflict) + return conflict.current_result_lines or conflict.current_lines or {} end local function display_ends_with_newline(conflict, current_ends_with_newline) @@ -666,8 +812,11 @@ local function apply_states(stable_segments, conflicts, outcomes) if not outcomes then for _, conflict in ipairs(conflicts) do conflict.state = 'unresolved' - conflict.current_lines = conflict.canonical_lines - conflict.current_kind = 'marker' + conflict.current_lines = vim.deepcopy(conflict.base_lines) + conflict.current_kind = 'clean' + conflict.current_result_lines = vim.deepcopy(conflict.base_lines) + conflict.ours_handled = false + conflict.theirs_handled = false conflict.handled = false end return @@ -678,7 +827,18 @@ local function apply_states(stable_segments, conflicts, outcomes) conflict.state = outcome.state conflict.current_lines = outcome.current_lines conflict.current_kind = outcome.kind - conflict.handled = conflict.state ~= 'unresolved' and conflict.state ~= 'manual_unresolved' + conflict.current_result_lines = outcome.display_lines or outcome.current_lines + + if conflict.state == 'manual_resolved' then + conflict.ours_handled = true + conflict.theirs_handled = true + else + conflict.ours_handled = outcome.ours_handled == true + conflict.theirs_handled = outcome.theirs_handled == true + end + + conflict.handled = conflict.state == 'manual_resolved' + or (conflict.ours_handled and conflict.theirs_handled) end end @@ -759,17 +919,57 @@ local function build_model(file, opts) assign_source_ranges(conflicts, base_lines, ours_lines, theirs_lines) for _, conflict in ipairs(conflicts) do + conflict.candidates = { + ours = vim.deepcopy(conflict.ours_lines), + theirs = vim.deepcopy(conflict.theirs_lines), + both_ours_then_theirs = vim.list_extend(vim.deepcopy(conflict.ours_lines), conflict.theirs_lines), + both_theirs_then_ours = vim.list_extend(vim.deepcopy(conflict.theirs_lines), conflict.ours_lines), + base_only = vim.deepcopy(conflict.base_lines), + } conflict.base_ends_with_newline = base_ends_with_newline conflict.ours_ends_with_newline = ours_ends_with_newline conflict.theirs_ends_with_newline = theirs_ends_with_newline end local resolved_stable_segments = stable_segments - local outcomes = infer_conflict_states_strict(stable_segments, conflicts, current_lines) + local outcomes = infer_conflict_states_strict(stable_segments, conflicts, current_lines, opts) if not outcomes then resolved_stable_segments, outcomes = infer_conflict_states_relaxed(stable_segments, conflicts, current_lines) + if stable_segments_contain_conflict_markers(resolved_stable_segments) then + resolved_stable_segments = stable_segments + outcomes = nil + end end apply_states(stable_segments, conflicts, outcomes) + if opts.previous_model then + local previous_model = opts.previous_model + if type(previous_model) == 'table' and type(previous_model.conflicts) == 'table' then + for index, conflict in ipairs(conflicts) do + local previous = previous_model.conflicts[index] + local previous_result = previous and (previous.current_result_lines or previous.display_lines or previous.current_lines) + if previous + and conflict.current_kind ~= 'marker' + and type(previous_result) == 'table' + and same_lines(conflict.current_result_lines or {}, previous_result) + then + if previous.state == 'manual_resolved' and conflict.state == 'manual_unresolved' then + conflict.state = 'manual_resolved' + conflict.ours_handled = true + conflict.theirs_handled = true + conflict.handled = true + elseif previous.handled == false then + conflict.state = previous.state + conflict.ours_handled = previous.ours_handled == true + conflict.theirs_handled = previous.theirs_handled == true + conflict.handled = false + end + + conflict.current_result_lines = previous_result + conflict.current_lines = previous_result + end + end + end + end resolved_stable_segments = resolved_stable_segments or stable_segments local result_lines, unresolved_count, result_ends_with_newline = @@ -794,11 +994,31 @@ local function build_model(file, opts) } end -local function persisted_conflict_lines(conflict) - if conflict.state == 'unresolved' then - return conflict.canonical_lines +local function serialize_unresolved_conflict(conflict) + local result_lines = display_lines_for(conflict) + local ours_lines = conflict.ours_handled and result_lines or conflict.ours_lines + local theirs_lines = conflict.theirs_handled and result_lines or conflict.theirs_lines + local lines = { '<<<<<<< Ours' } + + for _, line in ipairs(ours_lines) do + lines[#lines + 1] = line + end + + lines[#lines + 1] = '||||||| Base' + for _, line in ipairs(result_lines) do + lines[#lines + 1] = line + end + + lines[#lines + 1] = '=======' + for _, line in ipairs(theirs_lines) do + lines[#lines + 1] = line end + lines[#lines + 1] = '>>>>>>> Theirs' + return lines +end + +local function persisted_conflict_lines(conflict) if conflict.state == 'manual_unresolved' then if conflict.current_kind == 'marker' or contains_conflict_markers(conflict.current_lines) then return conflict.current_lines @@ -807,6 +1027,10 @@ local function persisted_conflict_lines(conflict) return nil, 'cannot safely save unresolved manual merge edits yet' end + if not conflict.handled then + return serialize_unresolved_conflict(conflict) + end + return conflict.current_lines end @@ -836,44 +1060,150 @@ local function build_persisted_lines(merge_model) return lines end -local function reconcile_with_previous_model(merge_model, previous_model) - if type(previous_model) ~= 'table' or type(previous_model.conflicts) ~= 'table' then - return +local function state_includes_ours(state) + return state == 'ours' or state == 'both_ours_then_theirs' or state == 'both_theirs_then_ours' +end + +local function state_includes_theirs(state) + return state == 'theirs' or state == 'both_ours_then_theirs' or state == 'both_theirs_then_ours' +end + +local function finalize_conflict_action(conflict) + if conflict.state == 'manual_resolved' then + conflict.ours_handled = true + conflict.theirs_handled = true + else + conflict.ours_handled = conflict.ours_handled == true + conflict.theirs_handled = conflict.theirs_handled == true end - for index, conflict in ipairs(merge_model.conflicts) do - local previous = previous_model.conflicts[index] - -- Untouched unresolved conflicts project base text in the clean result pane, so - -- base_only stays ambiguous until a later slice adds explicit resolution actions. - if previous and previous.handled == false and conflict.state == 'base_only' then - conflict.state = 'unresolved' - conflict.handled = false - end + if conflict.state == 'manual_unresolved' or conflict.state == 'manual_resolved' then + conflict.current_result_lines = conflict.current_result_lines or conflict.current_lines or {} + conflict.current_lines = conflict.current_result_lines + conflict.current_kind = 'manual' + else + conflict.current_result_lines = current_result_lines_for(conflict, conflict.state, conflict.current_result_lines) + conflict.current_lines = conflict.current_result_lines + conflict.current_kind = 'clean' end - local unresolved_count = 0 - for _, conflict in ipairs(merge_model.conflicts) do - if not conflict.handled then - unresolved_count = unresolved_count + 1 + conflict.handled = conflict.state == 'manual_resolved' + or (conflict.ours_handled and conflict.theirs_handled) +end + +local function accept_state(current_state, side) + local includes_ours = state_includes_ours(current_state) + local includes_theirs = state_includes_theirs(current_state) + + if side == 'ours' then + if includes_ours then + return current_state + end + if includes_theirs then + return 'both_theirs_then_ours' end + return 'ours' + end + + if includes_theirs then + return current_state end - merge_model.unresolved_count = unresolved_count + if includes_ours then + return 'both_ours_then_theirs' + end + return 'theirs' end function M.build(file, opts) return build_model(file, opts) end +function M.apply_action(merge_model, index, action) + if type(merge_model) ~= 'table' or type(merge_model.conflicts) ~= 'table' then + return nil, 'merge model is not active' + end + + local conflict = merge_model.conflicts[index] + if not conflict then + return nil, 'unknown conflict' + end + + if action == 'mark_resolved' then + if conflict.state ~= 'manual_unresolved' then + return nil, 'mark resolved is only available for manual unresolved conflicts' + end + conflict.state = 'manual_resolved' + finalize_conflict_action(conflict) + return conflict + end + + if action == 'reset_conflict' then + conflict.state = 'unresolved' + conflict.ours_handled = false + conflict.theirs_handled = false + finalize_conflict_action(conflict) + return conflict + end + + if action == 'accept_both_ours_then_theirs' then + conflict.state = 'both_ours_then_theirs' + conflict.ours_handled = true + conflict.theirs_handled = true + finalize_conflict_action(conflict) + return conflict + end + + if action == 'accept_both_theirs_then_ours' then + conflict.state = 'both_theirs_then_ours' + conflict.ours_handled = true + conflict.theirs_handled = true + finalize_conflict_action(conflict) + return conflict + end + + if action == 'accept_ours' or action == 'accept_theirs' then + local side = action == 'accept_ours' and 'ours' or 'theirs' + conflict.state = accept_state(conflict.state, side) + if side == 'ours' then + conflict.ours_handled = true + else + conflict.theirs_handled = true + end + 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 + finalize_conflict_action(conflict) + return conflict + end + + return nil, 'unknown merge action' +end + function M.prepare_write(file, current_lines, opts) opts = opts or {} local merge_model, err = build_model(file, { current_lines = current_lines, current_ends_with_newline = opts.current_ends_with_newline, + previous_model = opts.previous_model, + manual_clean_state = 'manual_unresolved', }) if not merge_model then return nil, err end - reconcile_with_previous_model(merge_model, opts.previous_model) if merge_model.inference_failed then return nil, 'cannot safely map the current merge result back to git conflict state' end diff --git a/lua/glance/merge/render.lua b/lua/glance/merge/render.lua index 697b48e..6043eaa 100644 --- a/lua/glance/merge/render.lua +++ b/lua/glance/merge/render.lua @@ -1,3 +1,5 @@ +local actions = require('glance.merge.actions') +local config = require('glance.config') local layout = require('glance.merge.layout') local M = {} @@ -34,7 +36,90 @@ local function set_window_label(win, label) vim.api.nvim_set_option_value('winbar', label or '', { win = win }) end -local function role_label(model, role) +local function pretty_state(state) + local labels = { + ours = 'ours', + theirs = 'theirs', + both_ours_then_theirs = 'both o/t', + both_theirs_then_ours = 'both t/o', + base_only = 'base', + manual_resolved = 'manual resolved', + manual_unresolved = 'manual unresolved', + unresolved = 'unresolved', + } + + return labels[state] or tostring(state):gsub('_', ' ') +end + +local function conflict_state_label(conflict) + if not conflict then + return nil + end + + if conflict.state == 'manual_unresolved' then + return 'manual unresolved' + end + if conflict.state == 'manual_resolved' then + return 'manual resolved' + end + if conflict.handled then + return 'handled: ' .. pretty_state(conflict.state) + end + if conflict.state == 'unresolved' then + return 'unresolved' + end + + return 'pending: ' .. pretty_state(conflict.state) +end + +local function conflict_marker_group(conflict) + if conflict.state == 'manual_unresolved' or conflict.state == 'manual_resolved' then + return 'GlanceConflictMarkerManual' + end + if conflict.handled then + return 'GlanceConflictMarkerHandled' + end + return 'GlanceConflictMarkerUnresolved' +end + +local function conflict_state_group(conflict) + if conflict.state == 'manual_unresolved' or conflict.state == 'manual_resolved' then + return 'GlanceConflictStateManual' + end + if conflict.handled then + return 'GlanceConflictStateHandled' + end + return 'GlanceConflictStateUnresolved' +end + +local function result_label(model, active_conflict_index) + local op = model.operation or {} + local parts = {} + local conflict = active_conflict_index and model.conflicts[active_conflict_index] or nil + + if op.prefix then + parts[#parts + 1] = op.prefix + end + + parts[#parts + 1] = 'Result' + if conflict then + parts[#parts + 1] = string.format('%d/%d', active_conflict_index, #model.conflicts) + parts[#parts + 1] = conflict_state_label(conflict) + end + parts[#parts + 1] = string.format('%d unresolved', model.unresolved_count) + if model.inference_failed then + parts[#parts + 1] = 'inference fallback' + end + + local label = table.concat(parts, ' | ') + local hint = conflict and actions.hint_text(conflict, config.options.merge.keymaps) or '' + if hint ~= '' then + return label .. '%=' .. hint + end + return label +end + +local function role_label(model, role, active_conflict_index) local op = model.operation or {} local parts = {} @@ -55,11 +140,7 @@ local function role_label(model, role) parts[#parts + 1] = op.ours_display end elseif role == layout.RESULT_ROLE then - parts[#parts + 1] = 'Result' - parts[#parts + 1] = string.format('%d unresolved', model.unresolved_count) - if model.inference_failed then - parts[#parts + 1] = 'inference fallback' - end + return result_label(model, active_conflict_index) end return table.concat(parts, ' | ') @@ -86,30 +167,64 @@ local function add_line_range(buf, start_line, count, group) end end -local function decorate_sources(buffers, model) +local function add_result_range(buf, start_line, count, line_group, opts) + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return + end + if not start_line or count <= 0 then + return + end + + opts = opts or {} + for index = start_line, start_line + count - 1 do + local extmark_opts = { + line_hl_group = line_group, + priority = opts.priority or 90, + } + + if opts.number_group then + extmark_opts.number_hl_group = opts.number_group + end + + vim.api.nvim_buf_set_extmark(buf, NS, index - 1, 0, extmark_opts) + end +end + +local function zero_line_placeholder(conflict) + return '[' .. conflict_state_label(conflict) .. ']' +end + +local function decorate_sources(buffers, model, active_conflict_index) clear_buffer(buffers.theirs) clear_buffer(buffers.ours) clear_buffer(buffers.result) - for _, conflict in ipairs(model.conflicts) do + for index, conflict in ipairs(model.conflicts) do add_line_range(buffers.theirs, conflict.theirs_range.start, conflict.theirs_range.count, 'GlanceDiffChangeOld') add_line_range(buffers.ours, conflict.ours_range.start, conflict.ours_range.count, 'GlanceDiffChangeNew') - local group = conflict.handled and 'GlanceAccentText' or 'DiffChange' - add_line_range(buffers.result, conflict.result_range.start, conflict.result_range.count, group) + local state_group = conflict_state_group(conflict) + local active_number_group = index == active_conflict_index and 'GlanceConflictActiveNumber' or nil + + add_result_range(buffers.result, conflict.result_range.start, conflict.result_range.count, state_group, { + number_group = active_number_group, + }) - local label = conflict.handled and ('handled: ' .. conflict.state) or 'unresolved' 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 extmark_opts = { + virt_lines = { { { zero_line_placeholder(conflict), conflict_marker_group(conflict) } } }, + priority = 100, + } + if active_number_group then + extmark_opts.number_hl_group = active_number_group + end - if conflict.result_range.count > 0 then - vim.api.nvim_buf_set_extmark(buffers.result, NS, conflict.result_range.start - 1, 0, { - virt_text = { { label, 'Comment' } }, - virt_text_pos = 'eol', - }) - else vim.api.nvim_buf_set_extmark(buffers.result, NS, anchor_line, 0, { - virt_lines = { { { label, 'Comment' } } }, + virt_lines = extmark_opts.virt_lines, + priority = extmark_opts.priority, + number_hl_group = extmark_opts.number_hl_group, }) end end @@ -137,32 +252,43 @@ local function prepare_result_buffer(diffview, buf, lines, path, ends_with_newli vim.api.nvim_set_option_value('endofline', ends_with_newline ~= false, { buf = buf }) end -function M.apply(diffview, panes, model, file) - prepare_source_buffer( - diffview, - panes.theirs.buf, - 'glance://merge/theirs/' .. file.path, - model.theirs_lines, - file.path - ) - prepare_source_buffer( - diffview, - panes.ours.buf, - 'glance://merge/ours/' .. file.path, - model.ours_lines, - file.path - ) - prepare_result_buffer(diffview, panes.result.buf, model.result_lines, file.path, model.result_ends_with_newline) - - set_window_label(panes.theirs.win, role_label(model, layout.THEIRS_ROLE)) - set_window_label(panes.ours.win, role_label(model, layout.OURS_ROLE)) - set_window_label(panes.result.win, role_label(model, layout.RESULT_ROLE)) +function M.decorate(panes, model, active_conflict_index) + set_window_label(panes.theirs.win, role_label(model, layout.THEIRS_ROLE, active_conflict_index)) + set_window_label(panes.ours.win, role_label(model, layout.OURS_ROLE, active_conflict_index)) + set_window_label(panes.result.win, role_label(model, layout.RESULT_ROLE, active_conflict_index)) decorate_sources({ theirs = panes.theirs.buf, ours = panes.ours.buf, result = panes.result.buf, - }, model) + }, model, active_conflict_index) +end + +function M.apply(diffview, panes, model, file, opts) + opts = opts or {} + + if opts.refresh_sources ~= false then + prepare_source_buffer( + diffview, + panes.theirs.buf, + 'glance://merge/theirs/' .. file.path, + model.theirs_lines, + file.path + ) + prepare_source_buffer( + diffview, + panes.ours.buf, + 'glance://merge/ours/' .. file.path, + model.ours_lines, + file.path + ) + end + + if opts.refresh_result ~= false then + prepare_result_buffer(diffview, panes.result.buf, model.result_lines, file.path, model.result_ends_with_newline) + end + + M.decorate(panes, model, opts.active_conflict_index) end return M diff --git a/lua/glance/ui.lua b/lua/glance/ui.lua index 5eb4c1e..6ec56f2 100644 --- a/lua/glance/ui.lua +++ b/lua/glance/ui.lua @@ -4,6 +4,13 @@ local pane_navigation = require('glance.pane_navigation') local M = {} local SEPARATOR_HL = 'GlanceSeparatorHover' +local STATUSLINE_HOVER_GROUPS = { + StatusLine = SEPARATOR_HL, + StatusLineNC = SEPARATOR_HL, +} +local WIN_SEPARATOR_HOVER_GROUPS = { + WinSeparator = SEPARATOR_HL, +} local WELCOME_FRAME_MS = 150 local ns = vim.api.nvim_create_namespace('glance_welcome') @@ -29,6 +36,9 @@ M.animation_tick = 0 M.starfield = nil M.starfield_key = nil M.separator_hover_win = nil +M.separator_hover_targets = {} +M.separator_hover_key_ns = nil +M.separator_hover_pending = false local function hex_to_rgb(color) return tonumber(color:sub(2, 3), 16), tonumber(color:sub(4, 5), 16), tonumber(color:sub(6, 7), 16) @@ -109,11 +119,12 @@ local function encode_winhighlight(items) return table.concat(encoded, ',') end -local function apply_separator_hover(win, active) +local function apply_separator_hover(win, active, groups) if not win or not vim.api.nvim_win_is_valid(win) then return end + groups = groups or WIN_SEPARATOR_HOVER_GROUPS local current = vim.api.nvim_get_option_value('winhighlight', { win = win }) local parsed = {} @@ -124,64 +135,187 @@ local function apply_separator_hover(win, active) end end - if active then - parsed.WinSeparator = SEPARATOR_HL - elseif parsed.WinSeparator == SEPARATOR_HL then - parsed.WinSeparator = nil + for group, highlight in pairs(groups) do + if active then + parsed[group] = highlight + elseif parsed[group] == highlight then + parsed[group] = nil + end end vim.api.nvim_win_set_option(win, 'winhighlight', encode_winhighlight(parsed)) end function M.clear_separator_hover() - if M.separator_hover_win and vim.api.nvim_win_is_valid(M.separator_hover_win) then - apply_separator_hover(M.separator_hover_win, false) + for _, target in ipairs(M.separator_hover_targets or {}) do + if target.win and vim.api.nvim_win_is_valid(target.win) then + apply_separator_hover(target.win, false, target.groups) + end end + M.separator_hover_win = nil + M.separator_hover_targets = {} end -local function hovered_separator_win(mouse) - if type(mouse) ~= 'table' or mouse.winid == 0 or mouse.line ~= 0 or mouse.column ~= 0 then - return nil +local function all_glance_windows() + local diffview = require('glance.diffview') + local wins = {} + + if filetree.win and vim.api.nvim_win_is_valid(filetree.win) then + wins[#wins + 1] = filetree.win + end + + for _, win in ipairs(diffview.content_wins()) do + if vim.api.nvim_win_is_valid(win) then + wins[#wins + 1] = win + end end + return wins +end + +local function ranges_overlap(start_a, finish_a, start_b, finish_b) + return start_a <= finish_b and start_b <= finish_a +end + +local function vertical_separator_targets(mouse) if not hoverable_separator_windows()[mouse.winid] then - return nil + return {} end local pos = vim.fn.win_screenpos(mouse.winid) local top = pos[1] local left = pos[2] if top == nil or left == nil or top <= 0 or left <= 0 then - return nil + return {} end local right_separator = left + vim.api.nvim_win_get_width(mouse.winid) if mouse.screencol ~= right_separator then - return nil + return {} + end + + return { + { win = mouse.winid, groups = WIN_SEPARATOR_HOVER_GROUPS }, + } +end + +local function horizontal_separator_targets(mouse) + local targets = {} + local wins = all_glance_windows() + + for _, win in ipairs(wins) do + local pos = vim.fn.win_screenpos(win) + local top = pos[1] + local left = pos[2] + + if top ~= nil and left ~= nil and top > 0 and left > 0 then + local width = vim.api.nvim_win_get_width(win) + local height = vim.api.nvim_win_get_height(win) + local separator_row = top + height + local start_col = left + local end_col = left + width - 1 + + if mouse.screenrow == separator_row and mouse.screencol >= start_col and mouse.screencol <= end_col then + for _, other in ipairs(wins) do + if other ~= win then + local other_pos = vim.fn.win_screenpos(other) + local other_top = other_pos[1] + local other_left = other_pos[2] + + if other_top == separator_row + 1 and other_left ~= nil and other_left > 0 then + local other_width = vim.api.nvim_win_get_width(other) + local other_start = other_left + local other_end = other_left + other_width - 1 + + if ranges_overlap(start_col, end_col, other_start, other_end) then + targets[#targets + 1] = { + win = win, + groups = STATUSLINE_HOVER_GROUPS, + } + break + end + end + end + end + end + end end - return mouse.winid + return targets +end + +local function separator_targets_key(targets) + local keys = {} + + for _, target in ipairs(targets) do + local groups = {} + for group, highlight in pairs(target.groups or {}) do + groups[#groups + 1] = group .. ':' .. highlight + end + table.sort(groups) + keys[#keys + 1] = string.format('%d|%s', target.win, table.concat(groups, ',')) + end + + table.sort(keys) + return table.concat(keys, ';') +end + +local function hovered_separator_targets(mouse) + if type(mouse) ~= 'table' or mouse.winid == 0 or mouse.line ~= 0 or mouse.column ~= 0 then + return {} + end + + local vertical = vertical_separator_targets(mouse) + if #vertical > 0 then + return vertical + end + + return horizontal_separator_targets(mouse) end function M.update_separator_hover(mouse) - local win = hovered_separator_win(mouse or vim.fn.getmousepos()) - if win == M.separator_hover_win then + local targets = hovered_separator_targets(mouse or vim.fn.getmousepos()) + if separator_targets_key(targets) == separator_targets_key(M.separator_hover_targets or {}) then return end M.clear_separator_hover() - if win then - apply_separator_hover(win, true) - M.separator_hover_win = win + for _, target in ipairs(targets) do + apply_separator_hover(target.win, true, target.groups) + end + + M.separator_hover_targets = targets + if #targets == 1 and targets[1].groups == WIN_SEPARATOR_HOVER_GROUPS then + M.separator_hover_win = targets[1].win + else + M.separator_hover_win = nil end end function M.setup_separator_hover() vim.opt.mousemoveevent = true - vim.keymap.set('n', '', function() - M.update_separator_hover() - end, { silent = true }) + + if M.separator_hover_key_ns then + return + end + + local mousemove_key = vim.keycode('') + M.separator_hover_key_ns = vim.on_key(function(key, typed) + if key ~= mousemove_key and typed ~= mousemove_key then + return + end + + if M.separator_hover_pending then + return + end + + M.separator_hover_pending = true + vim.schedule(function() + M.separator_hover_pending = false + M.update_separator_hover() + end) + end) end local function fract(value) diff --git a/tests/helpers/state.lua b/tests/helpers/state.lua index 0a9fe5d..7d9c244 100644 --- a/tests/helpers/state.lua +++ b/tests/helpers/state.lua @@ -165,6 +165,9 @@ function M.reset() end if ui then + if ui.separator_hover_key_ns then + pcall(vim.on_key, nil, ui.separator_hover_key_ns) + end ui.diff_open = false ui.welcome_buf = nil ui.welcome_win = nil @@ -172,6 +175,9 @@ function M.reset() ui.starfield = nil ui.starfield_key = nil ui.separator_hover_win = nil + ui.separator_hover_targets = {} + ui.separator_hover_key_ns = nil + ui.separator_hover_pending = false end reset_loaded_modules() diff --git a/tests/integration/diffview_spec.lua b/tests/integration/diffview_spec.lua index f2ca9b2..fa2f211 100644 --- a/tests/integration/diffview_spec.lua +++ b/tests/integration/diffview_spec.lua @@ -8,6 +8,22 @@ local function open_first_changed() return require('glance.diffview') end +local function merge_extmarks(buf) + local merge_ns = vim.api.nvim_get_namespaces().glance_merge + return vim.api.nvim_buf_get_extmarks(buf, merge_ns, 0, -1, { details = true }) +end + +local function has_extmark_detail(marks, key, value) + for _, mark in ipairs(marks) do + local details = mark[4] + if details and details[key] == value then + return true + end + end + + return false +end + local function open_custom_workspace(role_specs, opts) opts = opts or {} @@ -150,7 +166,11 @@ return { A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { 'base' }) A.contains(vim.api.nvim_get_option_value('winbar', { win = theirs_win }), 'Theirs | stage 3 | MERGE_HEAD') A.contains(vim.api.nvim_get_option_value('winbar', { win = ours_win }), 'Ours | stage 2 | HEAD') - A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), 'Result | 1 unresolved') + local result_winbar = vim.api.nvim_get_option_value('winbar', { win = result_win }) + A.contains(result_winbar, 'Result') + A.contains(result_winbar, '1/1') + A.contains(result_winbar, 'unresolved') + A.contains(result_winbar, '1 unresolved') A.equal(vim.api.nvim_get_current_win(), result_win) A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 1) end) @@ -199,10 +219,18 @@ return { local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') local result_win = workspace.get_win(diffview.workspace, 'merge_result') local theirs_win = workspace.get_win(diffview.workspace, 'merge_theirs') - local merge_ns = vim.api.nvim_get_namespaces().glance_merge - local marks = vim.api.nvim_buf_get_extmarks(result_buf, merge_ns, 0, -1, { details = true }) + local marks = merge_extmarks(result_buf) + local placeholder = '' A.truthy(#marks > 0) + 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] + break + end + end + + A.contains(placeholder, 'unresolved') A.equal(vim.api.nvim_get_current_win(), result_win) A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 1) @@ -244,7 +272,7 @@ return { A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), expected_result) A.equal(vim.api.nvim_get_option_value('modified', { buf = result_buf }), false) - A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), 'Result | 1 unresolved') + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '1 unresolved') A.equal(repo:read(repo.files.tracked), table.concat({ 'intro updated', 'first main', @@ -318,7 +346,7 @@ return { A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { 'main' }) A.equal(vim.api.nvim_get_option_value('modified', { buf = result_buf }), false) - A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), 'Result | 0 unresolved') + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '0 unresolved') A.equal(repo:read(repo.files.tracked), 'main\n') end) end, @@ -389,17 +417,153 @@ return { }) A.equal(vim.api.nvim_get_current_win(), result_win) A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 2) + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '1/2') vim.api.nvim_set_current_win(theirs_win) N.press(']x') A.equal(vim.api.nvim_get_current_win(), result_win) A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 6) + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '2/2') N.press(']x') A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 2) + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '1/2') N.press('[x') A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 6) + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '2/2') + end) + end, + }, + { + name = 'merge action bar renders configured keys and accept ours persists partial progress', + run = function() + N.with_repo('repo_conflict', function(repo) + require('glance').start() + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local workspace = require('glance.workspace') + + ui.open_file(filetree.files.conflicts[1]) + + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + local result_win = workspace.get_win(diffview.workspace, 'merge_result') + local marks = merge_extmarks(result_buf) + + local winbar = vim.api.nvim_get_option_value('winbar', { win = result_win }) + A.truthy(has_extmark_detail(marks, 'line_hl_group', 'GlanceConflictStateUnresolved')) + A.truthy(has_extmark_detail(marks, 'number_hl_group', 'GlanceConflictActiveNumber')) + A.contains(winbar, '1/1') + A.contains(winbar, 'unresolved') + A.contains(winbar, '\\o ours') + A.contains(winbar, '\\O skip ours') + + 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') + + 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')) + + diffview.close(true) + ui.open_file(filetree.files.conflicts[1]) + + local reopened_buf = workspace.get_buf(diffview.workspace, 'merge_result') + 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') + end) + end, + }, + { + name = 'merge actions can fully resolve a conflict without dropping to raw markers', + run = function() + N.with_repo('repo_conflict', function(repo) + require('glance').start() + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local workspace = require('glance.workspace') + + ui.open_file(filetree.files.conflicts[1]) + + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + 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') + + vim.api.nvim_buf_call(result_buf, function() + vim.cmd('write') + end) + + A.equal(repo:read(repo.files.tracked), 'main\n') + end) + end, + }, + { + name = 'manual result edits can be explicitly marked resolved and reopen cleanly', + run = function() + N.with_repo('repo_conflict', function(repo) + require('glance').start() + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local workspace = require('glance.workspace') + + ui.open_file(filetree.files.conflicts[1]) + + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + local result_win = workspace.get_win(diffview.workspace, 'merge_result') + + vim.api.nvim_buf_set_lines(result_buf, 0, -1, false, { 'custom merge draft' }) + vim.api.nvim_exec_autocmds('TextChanged', { + buffer = result_buf, + modeline = false, + }) + + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '1 unresolved') + + N.press('\\m') + + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), 'manual resolved') + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '0 unresolved') + + vim.api.nvim_buf_call(result_buf, function() + vim.cmd('write') + end) + + A.equal(repo:read(repo.files.tracked), 'custom merge draft\n') + + diffview.close(true) + ui.open_file(filetree.files.conflicts[1]) + + local reopened_buf = workspace.get_buf(diffview.workspace, 'merge_result') + local reopened_win = workspace.get_win(diffview.workspace, 'merge_result') + A.same(vim.api.nvim_buf_get_lines(reopened_buf, 0, -1, false), { 'custom merge draft' }) + A.contains(vim.api.nvim_get_option_value('winbar', { win = reopened_win }), 'manual resolved') + A.contains(vim.api.nvim_get_option_value('winbar', { win = reopened_win }), '0 unresolved') end) end, }, diff --git a/tests/integration/ui_spec.lua b/tests/integration/ui_spec.lua index 6087e3b..a77c703 100644 --- a/tests/integration/ui_spec.lua +++ b/tests/integration/ui_spec.lua @@ -89,6 +89,60 @@ return { end) end, }, + { + name = 'hover highlighting marks merge pane separators, including the horizontal split above result', + run = function() + N.with_repo('repo_conflict', function() + require('glance').start() + local filetree = require('glance.filetree') + local ui = require('glance.ui') + local diffview = require('glance.diffview') + local layout = require('glance.merge.layout') + local workspace = require('glance.workspace') + + ui.open_file(filetree.files.conflicts[1]) + + local theirs_win = workspace.get_win(diffview.workspace, layout.THEIRS_ROLE) + local ours_win = workspace.get_win(diffview.workspace, layout.OURS_ROLE) + local result_win = workspace.get_win(diffview.workspace, layout.RESULT_ROLE) + local result_pos = vim.fn.win_screenpos(result_win) + + ui.update_separator_hover({ + winid = result_win, + line = 0, + column = 0, + screenrow = result_pos[1] - 1, + screencol = result_pos[2] + 2, + }) + + local theirs_hl = vim.api.nvim_get_option_value('winhighlight', { win = theirs_win }) + local ours_hl = vim.api.nvim_get_option_value('winhighlight', { win = ours_win }) + A.contains(theirs_hl, 'StatusLine:GlanceSeparatorHover') + A.contains(theirs_hl, 'StatusLineNC:GlanceSeparatorHover') + A.falsy(ours_hl:find('StatusLine:GlanceSeparatorHover', 1, true)) + + ui.update_separator_hover({ + winid = theirs_win, + line = 0, + column = 0, + screenrow = result_pos[1] - 1, + screencol = result_pos[2] + vim.api.nvim_win_get_width(result_win) - 3, + }) + + theirs_hl = vim.api.nvim_get_option_value('winhighlight', { win = theirs_win }) + ours_hl = vim.api.nvim_get_option_value('winhighlight', { win = ours_win }) + A.falsy(theirs_hl:find('StatusLine:GlanceSeparatorHover', 1, true)) + A.contains(ours_hl, 'StatusLine:GlanceSeparatorHover') + A.contains(ours_hl, 'StatusLineNC:GlanceSeparatorHover') + + ui.clear_separator_hover() + theirs_hl = vim.api.nvim_get_option_value('winhighlight', { win = theirs_win }) + ours_hl = vim.api.nvim_get_option_value('winhighlight', { win = ours_win }) + A.falsy(theirs_hl:find('StatusLine:GlanceSeparatorHover', 1, true)) + A.falsy(ours_hl:find('StatusLine:GlanceSeparatorHover', 1, true)) + end) + end, + }, { name = 'opening a file closes welcome and routes to the correct diff opener', run = function() diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua index 855ae90..717636c 100644 --- a/tests/unit/config_spec.lua +++ b/tests/unit/config_spec.lua @@ -76,6 +76,18 @@ return { log = { max_commits = 200, }, + merge = { + 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', + reset_conflict = 'r', + mark_resolved = 'm', + }, + }, keymaps = { open_file = '', quit = 'q', @@ -144,6 +156,11 @@ return { log = { max_commits = 75, }, + merge = { + keymaps = { + accept_ours = 'go', + }, + }, theme = { preset = 'one_light', palette = { @@ -173,6 +190,8 @@ return { A.equal(config.options.windows.filetree.cursorline, true) A.equal(config.options.filetree.show_legend, false) A.equal(config.options.log.max_commits, 75) + A.equal(config.options.merge.keymaps.accept_ours, 'go') + A.equal(config.options.merge.keymaps.accept_theirs, 't') A.equal(config.options.windows.diff.relativenumber, false) A.equal(config.options.theme.preset, 'one_light') A.equal(config.options.theme.palette.logo, '#ffffff') diff --git a/tests/unit/merge_model_spec.lua b/tests/unit/merge_model_spec.lua index c7df4d2..3423fcb 100644 --- a/tests/unit/merge_model_spec.lua +++ b/tests/unit/merge_model_spec.lua @@ -201,5 +201,149 @@ return { end) end, }, + { + name = 'prepare_write does not treat plain base text as resolved without an explicit action', + run = function() + N.with_repo('repo_conflict', function() + local git = require('glance.git') + local merge_model = require('glance.merge.model') + local file = git.get_changed_files().conflicts[1] + local previous_model = assert(merge_model.build(file)) + + local prepared = assert(merge_model.prepare_write(file, { 'base' }, { + previous_model = previous_model, + })) + + A.equal(prepared.model.conflicts[1].state, 'unresolved') + A.falsy(prepared.model.conflicts[1].handled) + A.same(prepared.persisted_lines, { + '<<<<<<< Ours', + 'main', + '||||||| Base', + 'base', + '=======', + 'feature', + '>>>>>>> Theirs', + }) + end) + end, + }, + { + name = 'accept ours persists and rehydrates as a partial unresolved state', + 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] + local previous_model = assert(merge_model.build(file)) + + 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) + + local prepared = assert(merge_model.prepare_write(file, { 'main' }, { + previous_model = previous_model, + })) + + A.same(prepared.persisted_lines, { + '<<<<<<< Ours', + 'main', + '||||||| Base', + 'main', + '=======', + 'feature', + '>>>>>>> Theirs', + }) + + 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.same(reopened.result_lines, { 'main' }) + end) + end, + }, + { + name = 'mark resolved persists clean manual text and reopens as manual_resolved', + 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] + local manual_lines = { 'base + feature rewrite' } + + local previous_model = assert(merge_model.build(file)) + local rejected, err = merge_model.prepare_write(file, manual_lines, { + previous_model = previous_model, + }) + A.equal(rejected, nil) + A.contains(err, 'cannot safely save unresolved manual merge edits yet') + + local manual_model = assert(merge_model.build(file, { + current_lines = manual_lines, + current_ends_with_newline = true, + previous_model = previous_model, + manual_clean_state = 'manual_unresolved', + })) + + A.equal(manual_model.conflicts[1].state, 'manual_unresolved') + assert(merge_model.apply_action(manual_model, 1, 'mark_resolved')) + + local prepared = assert(merge_model.prepare_write(file, manual_lines, { + previous_model = manual_model, + })) + + A.equal(prepared.persisted_text, 'base + feature rewrite\n') + repo:write(repo.files.tracked, prepared.persisted_text) + + local reopened = assert(merge_model.build(file)) + A.equal(reopened.conflicts[1].state, 'manual_resolved') + A.truthy(reopened.conflicts[1].handled) + A.same(reopened.result_lines, manual_lines) + end) + end, + }, + { + name = 'accept ours keeps add/add conflicts visible and serializable with an empty base', + run = function() + N.with_repo('repo_conflict_add_add', function(repo) + local git = require('glance.git') + local merge_model = require('glance.merge.model') + local file = git.get_changed_files().conflicts[1] + local previous_model = assert(merge_model.build(file)) + + assert(merge_model.apply_action(previous_model, 1, 'accept_ours')) + + local prepared = assert(merge_model.prepare_write(file, { 'main add' }, { + previous_model = previous_model, + })) + + A.same(prepared.persisted_lines, { + '<<<<<<< Ours', + 'main add', + '||||||| Base', + 'main add', + '=======', + 'feature add', + '>>>>>>> Theirs', + }) + + 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].result_range.start, 1) + A.equal(reopened.conflicts[1].result_range.count, 1) + A.equal(reopened.unresolved_count, 1) + A.same(reopened.result_lines, { 'main add' }) + end) + end, + }, }, }