diff --git a/lua/codediff/ui/conflict/gutter/compute.lua b/lua/codediff/ui/conflict/gutter/compute.lua new file mode 100644 index 00000000..d6093555 --- /dev/null +++ b/lua/codediff/ui/conflict/gutter/compute.lua @@ -0,0 +1,74 @@ +-- Compute conflict gutter markers from visual positions. +local M = {} + +local function append_positions(positions, line, count) + for offset = 1, count do + positions[#positions + 1] = { line = line, offset = offset } + end +end + +--- Return the visual positions covered by one buffer-side range. +--- +--- Ranges are one-based and half-open. Filler counts are indexed by the +--- one-based real-line anchor after which the filler is displayed. Anchor zero +--- represents filler above the first buffer line. +function M.visual_positions(range, filler_counts) + if not range or range.start_line > range.end_line then + return {} + end + + filler_counts = filler_counts or {} + local positions = {} + + if range.start_line == range.end_line then + local anchor = range.start_line - 1 + local count = filler_counts[anchor] or 0 + if count == 0 then + anchor = range.start_line + count = filler_counts[anchor] or 0 + end + append_positions(positions, anchor, count) + return positions + end + + append_positions(positions, range.start_line - 1, filler_counts[range.start_line - 1] or 0) + for line = range.start_line, range.end_line - 1 do + positions[#positions + 1] = { line = line, offset = 0 } + append_positions(positions, line, filler_counts[line] or 0) + end + + return positions +end + +local function set_marker(markers, position, text) + local line_markers = markers[position.line] + if not line_markers then + line_markers = {} + markers[position.line] = line_markers + end + line_markers[position.offset] = text +end + +--- Compute one marker for every supplied visual position. +function M.compute_markers(positions) + local markers = {} + local count = #positions + + for index, position in ipairs(positions) do + local text + if count == 1 then + text = "[" + elseif index == 1 then + text = "╭─" + elseif index == count then + text = "╰─" + else + text = "│ " + end + set_marker(markers, position, text) + end + + return markers +end + +return M diff --git a/lua/codediff/ui/conflict/gutter/init.lua b/lua/codediff/ui/conflict/gutter/init.lua new file mode 100644 index 00000000..8e30238f --- /dev/null +++ b/lua/codediff/ui/conflict/gutter/init.lua @@ -0,0 +1,186 @@ +-- Coordinate conflict gutter computation and its Neovim renderers. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local tracking = require("codediff.ui.conflict.tracking") +local filler = require("codediff.ui.filler") +local compute = require("codediff.ui.conflict.gutter.compute") +local signcolumn = require("codediff.ui.conflict.gutter.signcolumn") +local statuscolumn = require("codediff.ui.conflict.gutter.statuscolumn") + +local function normalize_range(bufnr, range) + if not bufnr or not range or not vim.api.nvim_buf_is_valid(bufnr) then + return nil + end + + local line_count = vim.api.nvim_buf_line_count(bufnr) + if line_count == 0 then + return nil + end + + local start_line = math.max(1, math.min(range.start_line, line_count + 1)) + local end_line = math.max(1, math.min(range.end_line, line_count + 1)) + if end_line < start_line then + return nil + end + + return { start_line = start_line, end_line = end_line } +end + +local function compute_projection(bufnr, range, filler_counts, highlight) + local normalized = normalize_range(bufnr, range) + if not normalized then + return nil + end + + local positions = compute.visual_positions(normalized, filler_counts) + if #positions == 0 then + return nil + end + + return { + markers = compute.compute_markers(positions), + highlight = highlight, + } +end + +local function accepted_highlights(session, block) + if tracking.is_block_active(session, block) then + return "CodeDiffConflictSign", "CodeDiffConflictSign", "CodeDiffConflictSign" + end + + local accepted = tracking.get_accepted_side(session, block) + if accepted == "incoming" then + return "CodeDiffConflictSignAccepted", "CodeDiffConflictSignRejected", "CodeDiffConflictSignResolved" + elseif accepted == "current" then + return "CodeDiffConflictSignRejected", "CodeDiffConflictSignAccepted", "CodeDiffConflictSignResolved" + elseif accepted == "both" then + return "CodeDiffConflictSignAccepted", "CodeDiffConflictSignAccepted", "CodeDiffConflictSignResolved" + end + return "CodeDiffConflictSignResolved", "CodeDiffConflictSignResolved", "CodeDiffConflictSignResolved" +end + +local function tracking_needs_reinit(session) + for _, block in ipairs(session.conflict_blocks) do + if not block.extmark_id then + return true + end + local mark = vim.api.nvim_buf_get_extmark_by_id(session.result_bufnr, tracking.tracking_ns, block.extmark_id, { details = true }) + if not mark or #mark < 3 or not mark[3] then + return true + end + local expected_range = block.result_range or block.base_range + if mark[1] == 0 and expected_range.start_line - 1 > 0 then + return true + end + end + return false +end + +local function clear_markers(session, namespace) + for _, bufnr in ipairs({ session.original_bufnr, session.modified_bufnr }) do + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) + end + end + if session.result_bufnr and vim.api.nvim_buf_is_valid(session.result_bufnr) then + vim.api.nvim_buf_clear_namespace(session.result_bufnr, tracking.result_signs_ns, 0, -1) + end +end + +--- Recompute all conflict marker maps and render them in every buffer. +function M.refresh(session) + if not session or not session.conflict_blocks then + return + end + + local highlights = require("codediff.ui.highlights") + local ns_conflict = highlights.ns_conflict + + if tracking_needs_reinit(session) and session.result_bufnr and vim.api.nvim_buf_is_valid(session.result_bufnr) then + tracking.initialize_tracking(session.result_bufnr, session.conflict_blocks) + end + + clear_markers(session, ns_conflict) + + local original_counts = filler.get_virt_line_counts(session.original_bufnr) + local modified_counts = filler.get_virt_line_counts(session.modified_bufnr) + local result_counts = filler.get_virt_line_counts(session.result_bufnr) + local original_projections = {} + local modified_projections = {} + local result_projections = {} + + for _, block in ipairs(session.conflict_blocks) do + local left_highlight, right_highlight, result_highlight = accepted_highlights(session, block) + + local original = compute_projection(session.original_bufnr, block.output1_range, original_counts, left_highlight) + if original then + original_projections[#original_projections + 1] = original + end + + local modified = compute_projection(session.modified_bufnr, block.output2_range, modified_counts, right_highlight) + if modified then + modified_projections[#modified_projections + 1] = modified + end + + if session.result_bufnr and vim.api.nvim_buf_is_valid(session.result_bufnr) and block.extmark_id then + local mark = vim.api.nvim_buf_get_extmark_by_id(session.result_bufnr, tracking.tracking_ns, block.extmark_id, { details = true }) + if mark and #mark >= 3 and mark[3].end_row then + local result = compute_projection(session.result_bufnr, { start_line = mark[1] + 1, end_line = mark[3].end_row + 1 }, result_counts, result_highlight) + if result then + result_projections[#result_projections + 1] = result + end + end + end + end + + signcolumn.render(session.original_bufnr, original_projections, ns_conflict) + signcolumn.render(session.modified_bufnr, modified_projections, ns_conflict) + signcolumn.render(session.result_bufnr, result_projections, tracking.result_signs_ns) + statuscolumn.update(session.original_win, original_projections, original_counts) + statuscolumn.update(session.modified_win, modified_projections, modified_counts) +end + +--- Attach gutter rendering to the two conflict input windows. +function M.attach(original_win, modified_win) + statuscolumn.attach(original_win) + statuscolumn.attach(modified_win) +end + +--- Detach gutter rendering from one conflict input window. +function M.detach(win) + statuscolumn.detach(win) +end + +--- Release all gutter resources before a conflict view is retargeted or closed. +function M.teardown(tabpage) + local session = lifecycle.get_session(tabpage) + if session then + clear_markers(session, require("codediff.ui.highlights").ns_conflict) + M.detach(session.original_win) + M.detach(session.modified_win) + end + pcall(vim.api.nvim_del_augroup_by_name, "CodeDiffConflictGutter_" .. tabpage) +end + +--- Setup an autocmd to refresh gutter markers when the result changes. +function M.setup_refresh_autocmd(tabpage, result_bufnr) + if not result_bufnr or not vim.api.nvim_buf_is_valid(result_bufnr) then + return + end + + local group = vim.api.nvim_create_augroup("CodeDiffConflictGutter_" .. tabpage, { clear = true }) + + vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { + group = group, + buffer = result_bufnr, + callback = function() + local session = lifecycle.get_session(tabpage) + if session and not session.suspended and session.result_bufnr == result_bufnr then + M.refresh(session) + end + end, + }) +end + +return M diff --git a/lua/codediff/ui/conflict/gutter/signcolumn.lua b/lua/codediff/ui/conflict/gutter/signcolumn.lua new file mode 100644 index 00000000..c2b61d75 --- /dev/null +++ b/lua/codediff/ui/conflict/gutter/signcolumn.lua @@ -0,0 +1,24 @@ +-- Render precomputed conflict markers in the native signcolumn. +local M = {} + +--- Render the real-line markers from one or more buffer projections. +function M.render(bufnr, projections, namespace) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + for _, projection in ipairs(projections or {}) do + for line, offsets in pairs(projection.markers or {}) do + local text = offsets[0] + if text and line >= 1 then + vim.api.nvim_buf_set_extmark(bufnr, namespace, line - 1, 0, { + sign_text = text, + sign_hl_group = projection.highlight, + priority = 50, + }) + end + end + end +end + +return M diff --git a/lua/codediff/ui/conflict/gutter/statuscolumn.lua b/lua/codediff/ui/conflict/gutter/statuscolumn.lua new file mode 100644 index 00000000..3b90b9d8 --- /dev/null +++ b/lua/codediff/ui/conflict/gutter/statuscolumn.lua @@ -0,0 +1,146 @@ +-- Render precomputed conflict markers for filler virtual lines. +local M = {} + +local states = {} +local expression = "%!v:lua.require'codediff.ui.conflict.gutter.statuscolumn'.render()" + +local function number_item(win) + local options = vim.wo[win] + if not options.number and not options.relativenumber then + return "" + end + + -- %l preserves native relative numbers, hybrid alignment and wrapped rows. + -- With signcolumn=number, signs need right alignment even on the cursor row. + if options.signcolumn == "number" then + local cursor_number = options.number and options.relativenumber and vim.v.relnum == 0 and vim.v.virtnum == 0 + if cursor_number then + local row = vim.v.lnum - 1 + local signs = vim.api.nvim_buf_get_extmarks(vim.api.nvim_win_get_buf(win), -1, { row, 0 }, { row, -1 }, { type = "sign", limit = 1 }) + cursor_number = #signs == 0 + end + if not cursor_number then + return "%=%l " + end + end + return "%l " +end + +local function default_statuscolumn(win) + return "%C%s" .. number_item(win) +end + +local function render_marker(win, marker) + local marker_text = marker.text == "[" and "[ " or marker.text + local highlighted = "%#" .. marker.highlight .. "#" .. marker_text .. "%*" + local options = vim.wo[win] + if options.signcolumn == "number" and (options.number or options.relativenumber) then + return "%C%=" .. highlighted .. " " + end + return "%C" .. highlighted .. number_item(win) +end + +--- Render in the drawing window's context, even if another pane has focus. +function M.render() + local win = tonumber(vim.g.statusline_winid) + if not win or not vim.api.nvim_win_is_valid(win) then + return "" + end + local state = states[win] + if state and state.bufnr == vim.api.nvim_win_get_buf(win) and vim.v.virtnum < 0 and vim.wo[win].signcolumn ~= "no" then + local line_markers = state.markers[vim.v.lnum] + local marker = line_markers and line_markers[-vim.v.virtnum] + if marker then + return render_marker(win, marker) + end + end + return default_statuscolumn(win) +end + +--- Attach only when the column is native or inherited from our renderer. +function M.attach(win) + if vim.fn.exists("+statuscolumn") ~= 1 or not win or not vim.api.nvim_win_is_valid(win) then + return false + end + + local current = vim.wo[win].statuscolumn + if current ~= "" and current ~= expression then + states[win] = nil + return false + end + + local bufnr = vim.api.nvim_win_get_buf(win) + local state = states[win] + if not state or state.bufnr ~= bufnr then + states[win] = { bufnr = bufnr, markers = {} } + end + if current ~= expression then + vim.wo[win].statuscolumn = expression + end + return true +end + +--- Index precomputed markers by Neovim's statuscolumn coordinates. +function M.update(win, projections, filler_counts) + local state = states[win] + if not state or not vim.api.nvim_win_is_valid(win) or state.bufnr ~= vim.api.nvim_win_get_buf(win) or vim.wo[win].statuscolumn ~= expression then + M.detach(win) + return false + end + + local markers = {} + local bof_count = filler_counts and filler_counts[0] or 0 + for _, projection in ipairs(projections or {}) do + for line, offsets in pairs(projection.markers) do + for offset, text in pairs(offsets) do + if offset > 0 then + -- BOF fillers and fillers after line one share v:lnum=1; Neovim + -- numbers them consecutively, including off-screen BOF fillers. + local lnum = math.max(1, line) + local virtnum = offset + (line == 1 and bof_count or 0) + markers[lnum] = markers[lnum] or {} + markers[lnum][virtnum] = { text = text, highlight = projection.highlight } + end + end + end + end + state.markers = markers + return true +end + +--- Restore the option only while we still own it. +function M.detach(win) + local state = states[win] + if not state then + return + end + states[win] = nil + if vim.api.nvim_win_is_valid(win) and vim.wo[win].statuscolumn == expression then + vim.wo[win].statuscolumn = "" + end +end + +if vim.fn.exists("+statuscolumn") == 1 then + local group = vim.api.nvim_create_augroup("CodeDiffConflictGutterWindows", { clear = true }) + vim.api.nvim_create_autocmd("WinClosed", { + group = group, + callback = function(event) + states[tonumber(event.match)] = nil + end, + }) + vim.api.nvim_create_autocmd("BufWinEnter", { + group = group, + callback = function() + local win = vim.api.nvim_get_current_win() + local state = states[win] + if state and state.bufnr ~= vim.api.nvim_win_get_buf(win) then + M.detach(win) + elseif not state and vim.wo[win].statuscolumn == expression then + -- A split can inherit the expression, but not ownership of its state. + vim.wo[win].statuscolumn = "" + end + end, + }) +end + +return M diff --git a/lua/codediff/ui/conflict/init.lua b/lua/codediff/ui/conflict/init.lua index 47e8e960..50a4773a 100644 --- a/lua/codediff/ui/conflict/init.lua +++ b/lua/codediff/ui/conflict/init.lua @@ -4,7 +4,7 @@ local M = {} -- Import submodules local tracking = require("codediff.ui.conflict.tracking") -local signs = require("codediff.ui.conflict.signs") +local gutter = require("codediff.ui.conflict.gutter") local resolution = require("codediff.ui.conflict.resolution") local navigation = require("codediff.ui.conflict.navigation") local keymaps = require("codediff.ui.conflict.keymaps") @@ -13,9 +13,12 @@ local keymaps = require("codediff.ui.conflict.keymaps") M.run_repeatable_action = tracking.run_repeatable_action M.initialize_tracking = tracking.initialize_tracking --- Delegate to signs module -M.refresh_all_conflict_signs = signs.refresh_all_conflict_signs -M.setup_sign_refresh_autocmd = signs.setup_sign_refresh_autocmd +-- Delegate to the conflict renderer +M.refresh = gutter.refresh +M.setup_refresh_autocmd = gutter.setup_refresh_autocmd +M.attach_gutter = gutter.attach +M.detach_gutter = gutter.detach +M.teardown_gutter = gutter.teardown -- Delegate to resolution module M.accept_incoming = resolution.accept_incoming diff --git a/lua/codediff/ui/conflict/resolution/block.lua b/lua/codediff/ui/conflict/resolution/block.lua index ae2e56a7..ad494020 100644 --- a/lua/codediff/ui/conflict/resolution/block.lua +++ b/lua/codediff/ui/conflict/resolution/block.lua @@ -4,7 +4,7 @@ local M = {} local lifecycle = require("codediff.ui.lifecycle") local auto_refresh = require("codediff.ui.auto_refresh") local tracking = require("codediff.ui.conflict.tracking") -local signs = require("codediff.ui.conflict.signs") +local gutter = require("codediff.ui.conflict.gutter") local apply_to_result = require("codediff.ui.conflict.resolution.replace").apply_to_result local combine = require("codediff.ui.conflict.resolution.combine") local smart_combine_inputs = combine.smart_combine_inputs @@ -57,7 +57,7 @@ function M.accept_incoming(tabpage) end apply_to_result(result_bufnr, block, incoming_lines, base_lines) - signs.refresh_all_conflict_signs(session) + gutter.refresh(session) auto_refresh.refresh_result_now(result_bufnr) return true end @@ -107,7 +107,7 @@ function M.accept_current(tabpage) end apply_to_result(result_bufnr, block, current_lines, base_lines) - signs.refresh_all_conflict_signs(session) + gutter.refresh(session) auto_refresh.refresh_result_now(result_bufnr) return true end @@ -169,7 +169,7 @@ function M.accept_both(tabpage) end apply_to_result(result_bufnr, block, combined, base_lines) - signs.refresh_all_conflict_signs(session) + gutter.refresh(session) auto_refresh.refresh_result_now(result_bufnr) return true end @@ -231,7 +231,7 @@ function M.discard(tabpage) -- apply_to_result indexes its base_lines parameter by result_range for the -- content-search fallback, so pass the Result seed (auto-merged content). apply_to_result(result_bufnr, block, base_content, session.result_base_lines or base_lines) - signs.refresh_all_conflict_signs(session) + gutter.refresh(session) auto_refresh.refresh_result_now(result_bufnr) return true end diff --git a/lua/codediff/ui/conflict/resolution/diffget.lua b/lua/codediff/ui/conflict/resolution/diffget.lua index 070e6407..8b92d413 100644 --- a/lua/codediff/ui/conflict/resolution/diffget.lua +++ b/lua/codediff/ui/conflict/resolution/diffget.lua @@ -4,7 +4,7 @@ local M = {} local lifecycle = require("codediff.ui.lifecycle") local auto_refresh = require("codediff.ui.auto_refresh") local tracking = require("codediff.ui.conflict.tracking") -local signs = require("codediff.ui.conflict.signs") +local gutter = require("codediff.ui.conflict.gutter") --- Apply text to result buffer at the conflict's range --- @param result_bufnr number Result buffer @@ -105,7 +105,7 @@ function M.diffget_incoming(tabpage) end apply_to_result(result_bufnr, block, incoming_lines, base_lines) - signs.refresh_all_conflict_signs(session) + gutter.refresh(session) auto_refresh.refresh_result_now(result_bufnr) return true end @@ -153,7 +153,7 @@ function M.diffget_current(tabpage) end apply_to_result(result_bufnr, block, current_lines, base_lines) - signs.refresh_all_conflict_signs(session) + gutter.refresh(session) auto_refresh.refresh_result_now(result_bufnr) return true end diff --git a/lua/codediff/ui/conflict/resolution/file.lua b/lua/codediff/ui/conflict/resolution/file.lua index 9608a071..84885faa 100644 --- a/lua/codediff/ui/conflict/resolution/file.lua +++ b/lua/codediff/ui/conflict/resolution/file.lua @@ -4,7 +4,7 @@ local M = {} local lifecycle = require("codediff.ui.lifecycle") local auto_refresh = require("codediff.ui.auto_refresh") local tracking = require("codediff.ui.conflict.tracking") -local signs = require("codediff.ui.conflict.signs") +local gutter = require("codediff.ui.conflict.gutter") local apply_to_result = require("codediff.ui.conflict.resolution.replace").apply_to_result --- Accept ALL incoming (left/input1) for all active conflicts @@ -47,7 +47,7 @@ function M.accept_all_incoming(tabpage) end end) - signs.refresh_all_conflict_signs(session) + gutter.refresh(session) auto_refresh.refresh_result_now(result_bufnr) vim.notify(string.format("[codediff] Accepted %d incoming change(s)", count), vim.log.levels.INFO) return count > 0 @@ -91,7 +91,7 @@ function M.accept_all_current(tabpage) end end) - signs.refresh_all_conflict_signs(session) + gutter.refresh(session) auto_refresh.refresh_result_now(result_bufnr) vim.notify(string.format("[codediff] Accepted %d current change(s)", count), vim.log.levels.INFO) return count > 0 @@ -149,7 +149,7 @@ function M.accept_all_both(tabpage, first_input) end end) - signs.refresh_all_conflict_signs(session) + gutter.refresh(session) auto_refresh.refresh_result_now(result_bufnr) vim.notify(string.format("[codediff] Accepted %d combined change(s)", count), vim.log.levels.INFO) return count > 0 @@ -200,7 +200,7 @@ function M.discard_all(tabpage) end end) - signs.refresh_all_conflict_signs(session) + gutter.refresh(session) auto_refresh.refresh_result_now(result_bufnr) vim.notify(string.format("[codediff] Reset %d conflict(s) to base", count), vim.log.levels.INFO) return count > 0 diff --git a/lua/codediff/ui/conflict/signs.lua b/lua/codediff/ui/conflict/signs.lua deleted file mode 100644 index cf1d7af3..00000000 --- a/lua/codediff/ui/conflict/signs.lua +++ /dev/null @@ -1,192 +0,0 @@ --- Conflict sign management for merge tool --- Handles sign refresh and autocmd setup -local M = {} - -local lifecycle = require("codediff.ui.lifecycle") -local tracking = require("codediff.ui.conflict.tracking") - ---- Refresh all conflict signs based on current state (event-driven approach) ---- Called on TextChanged to keep signs in sync with actual buffer content ---- Also used for initial sign setup after initialize_tracking ---- @param session table The diff session -function M.refresh_all_conflict_signs(session) - if not session or not session.conflict_blocks then - return - end - - local highlights = require("codediff.ui.highlights") - local ns_conflict = highlights.ns_conflict - - -- Check if extmarks need re-initialization (e.g., after undo to original state) - -- If any extmark is missing or invalid, re-initialize all tracking - local needs_reinit = false - for _, block in ipairs(session.conflict_blocks) do - if not block.extmark_id then - needs_reinit = true - break - end - local mark = vim.api.nvim_buf_get_extmark_by_id(session.result_bufnr, tracking.tracking_ns, block.extmark_id, { details = true }) - if not mark or #mark < 3 or not mark[3] then - needs_reinit = true - break - end - -- Check if extmark position is reasonable (not spanning entire buffer or starting at 0 when it shouldn't) - local mark_start = mark[1] - local mark_end = mark[3].end_row - -- The extmark is placed at the block's position in the auto-merged Result - -- buffer (result_range), so compare against that. Legacy blocks without - -- result_range fall back to base_range. - local expected_range = block.result_range or block.base_range - local expected_start = expected_range.start_line - 1 - -- If extmark start moved to 0 but expected start is not 0, it's corrupted (common after undo/redo) - if mark_start == 0 and expected_start > 0 then - needs_reinit = true - break - end - end - - if needs_reinit and session.result_bufnr and vim.api.nvim_buf_is_valid(session.result_bufnr) then - tracking.initialize_tracking(session.result_bufnr, session.conflict_blocks) - end - - -- Helper to set signs for a buffer range (for non-empty ranges) - local function set_signs_for_range(bufnr, start_line, end_line, namespace, hl_group, is_active) - if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then - return - end - - local line_count = vim.api.nvim_buf_line_count(bufnr) - - if start_line == end_line then - -- Empty range: show a top-aligned horizontal bar sign to indicate "something goes here" - if start_line >= 0 and start_line < line_count then - local marks = vim.api.nvim_buf_get_extmarks(bufnr, namespace, { start_line, 0 }, { start_line, -1 }, {}) - for _, mark in ipairs(marks) do - vim.api.nvim_buf_del_extmark(bufnr, namespace, mark[1]) - end - vim.api.nvim_buf_set_extmark(bufnr, namespace, start_line, 0, { - sign_text = "▔▔", -- Upper block - appears at top of line, like between two lines - sign_hl_group = hl_group, - priority = 50, - }) - end - else - for line = start_line, end_line - 1 do - if line >= 0 and line < line_count then - local marks = vim.api.nvim_buf_get_extmarks(bufnr, namespace, { line, 0 }, { line, -1 }, {}) - for _, mark in ipairs(marks) do - vim.api.nvim_buf_del_extmark(bufnr, namespace, mark[1]) - end - vim.api.nvim_buf_set_extmark(bufnr, namespace, line, 0, { - sign_text = "▌", - sign_hl_group = hl_group, - priority = 50, - }) - end - end - end - end - - -- Clear result buffer signs (they move with content, so clear and re-add) - if session.result_bufnr and vim.api.nvim_buf_is_valid(session.result_bufnr) then - vim.api.nvim_buf_clear_namespace(session.result_bufnr, tracking.result_signs_ns, 0, -1) - end - - -- Update signs for each block based on is_block_active state - for _, block in ipairs(session.conflict_blocks) do - local is_active = tracking.is_block_active(session, block) - - -- Determine highlight groups for left/right/result based on accepted side - local left_hl, right_hl, result_hl - if is_active then - -- Unresolved: all orange - left_hl = "CodeDiffConflictSign" - right_hl = "CodeDiffConflictSign" - result_hl = "CodeDiffConflictSign" - else - -- Resolved: check which side was accepted - local accepted = tracking.get_accepted_side(session, block) - if accepted == "incoming" then - left_hl = "CodeDiffConflictSignAccepted" -- Green (chosen) - right_hl = "CodeDiffConflictSignRejected" -- Red (not chosen) - elseif accepted == "current" then - left_hl = "CodeDiffConflictSignRejected" -- Red (not chosen) - right_hl = "CodeDiffConflictSignAccepted" -- Green (chosen) - elseif accepted == "both" then - left_hl = "CodeDiffConflictSignAccepted" -- Green (both chosen) - right_hl = "CodeDiffConflictSignAccepted" -- Green (both chosen) - else - -- Manual edit or unknown - use gray - left_hl = "CodeDiffConflictSignResolved" - right_hl = "CodeDiffConflictSignResolved" - end - -- Result buffer always uses gray for resolved - result_hl = "CodeDiffConflictSignResolved" - end - - -- Update left buffer (incoming) - local left_start = block.output1_range.start_line - 1 - local left_end = block.output1_range.end_line - 1 - set_signs_for_range(session.original_bufnr, left_start, left_end, ns_conflict, left_hl, is_active) - - -- Update right buffer (current) - local right_start = block.output2_range.start_line - 1 - local right_end = block.output2_range.end_line - 1 - set_signs_for_range(session.modified_bufnr, right_start, right_end, ns_conflict, right_hl, is_active) - - -- Update result buffer (use tracked extmark position) - if session.result_bufnr and vim.api.nvim_buf_is_valid(session.result_bufnr) and block.extmark_id then - local mark = vim.api.nvim_buf_get_extmark_by_id(session.result_bufnr, tracking.tracking_ns, block.extmark_id, { details = true }) - if mark and #mark >= 3 then - local result_start = mark[1] - local result_end = mark[3].end_row - local line_count = vim.api.nvim_buf_line_count(session.result_bufnr) - - if result_start == result_end then - -- Empty conflict region: show a top-aligned horizontal bar sign - if result_start >= 0 and result_start < line_count then - vim.api.nvim_buf_set_extmark(session.result_bufnr, tracking.result_signs_ns, result_start, 0, { - sign_text = "▔▔", -- Upper block - appears at top of line, like between two lines - sign_hl_group = result_hl, - priority = 50, - }) - end - else - for line = result_start, result_end - 1 do - if line >= 0 and line < line_count then - vim.api.nvim_buf_set_extmark(session.result_bufnr, tracking.result_signs_ns, line, 0, { - sign_text = "▌", - sign_hl_group = result_hl, - priority = 50, - }) - end - end - end - end - end - end -end - ---- Setup autocmd to refresh signs when result buffer changes ---- @param tabpage number The tabpage ID ---- @param result_bufnr number The result buffer handle -function M.setup_sign_refresh_autocmd(tabpage, result_bufnr) - if not result_bufnr or not vim.api.nvim_buf_is_valid(result_bufnr) then - return - end - - local group = vim.api.nvim_create_augroup("CodeDiffConflictSigns_" .. tabpage, { clear = true }) - - vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { - group = group, - buffer = result_bufnr, - callback = function() - local session = lifecycle.get_session(tabpage) - if session then - M.refresh_all_conflict_signs(session) - end - end, - }) -end - -return M diff --git a/lua/codediff/ui/conflict/view/result.lua b/lua/codediff/ui/conflict/view/result.lua index 49c23905..b5e4839c 100644 --- a/lua/codediff/ui/conflict/view/result.lua +++ b/lua/codediff/ui/conflict/view/result.lua @@ -169,14 +169,15 @@ function M.setup_conflict_result_window(tabpage, session_config, original_win, m -- Initialize conflict tracking (keymaps setup separately after setup_all_keymaps) local conflict = require("codediff.ui.conflict") conflict.initialize_tracking(result_bufnr, conflict_diffs.conflict_blocks) + conflict.attach_gutter(original_win, modified_win) - -- Setup autocmd to refresh signs when result buffer changes (event-driven approach) - conflict.setup_sign_refresh_autocmd(tabpage, result_bufnr) + -- Setup autocmd to refresh markers when the result buffer changes. + conflict.setup_refresh_autocmd(tabpage, result_bufnr) - -- Initialize all conflict signs (uses refresh_all_conflict_signs for centralized logic) + -- Initialize all conflict markers through the centralized renderer. local session = lifecycle.get_session(tabpage) if session then - conflict.refresh_all_conflict_signs(session) + conflict.refresh(session) end -- Return focus to modified window diff --git a/lua/codediff/ui/filler.lua b/lua/codediff/ui/filler.lua index d1820893..2c5d2167 100644 --- a/lua/codediff/ui/filler.lua +++ b/lua/codediff/ui/filler.lua @@ -41,6 +41,25 @@ local function get_line() return cached_line end +--- Return virtual-line counts indexed by the one-based line anchor. +function M.get_virt_line_counts(bufnr) + local counts = {} + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return counts + end + + local marks = vim.api.nvim_buf_get_extmarks(bufnr, highlights.ns_filler, 0, -1, { details = true }) + for _, mark in ipairs(marks) do + local details = mark[4] + local count = details and details.virt_lines and #details.virt_lines or 0 + if count > 0 then + local anchor = details.virt_lines_above and mark[2] or mark[2] + 1 + counts[anchor] = (counts[anchor] or 0) + count + end + end + return counts +end + -- Insert virtual filler lines using extmarks function M.place(bufnr, after_line_0idx, count, opts) if count <= 0 then diff --git a/lua/codediff/ui/lifecycle/accessors.lua b/lua/codediff/ui/lifecycle/accessors.lua index 0e86d2b2..aad75a6a 100644 --- a/lua/codediff/ui/lifecycle/accessors.lua +++ b/lua/codediff/ui/lifecycle/accessors.lua @@ -422,8 +422,15 @@ function M.set_result(tabpage, result_bufnr, result_win) -- Leaving conflict mode: retire the conflict mappings so do/dp and the -- ordinary view mappings can be claimed again on the next setup pass. - if result_bufnr == nil and sess.result_bufnr ~= nil and sess.keymaps then - sess.keymaps:release_scope("conflict") + if result_bufnr == nil and sess.result_bufnr ~= nil then + if sess.keymaps then + sess.keymaps:release_scope("conflict") + end + require("codediff.ui.conflict").teardown_gutter(tabpage) + require("codediff.ui.auto_refresh").disable_result(sess.result_bufnr) + sess.conflict_blocks = nil + sess.result_base_lines = nil + sess.merge_base_lines = nil end sess.result_bufnr = result_bufnr diff --git a/lua/codediff/ui/lifecycle/cleanup.lua b/lua/codediff/ui/lifecycle/cleanup.lua index c15940d0..c32f5736 100644 --- a/lua/codediff/ui/lifecycle/cleanup.lua +++ b/lua/codediff/ui/lifecycle/cleanup.lua @@ -38,6 +38,12 @@ local function cleanup_diff(tabpage) local auto_refresh = require("codediff.ui.auto_refresh") auto_refresh.disable(diff.original_bufnr) auto_refresh.disable(diff.modified_bufnr) + if diff.result_bufnr then + auto_refresh.disable_result(diff.result_bufnr) + end + + -- Restore gutter options before deleting buffers can close or reuse windows. + require("codediff.ui.conflict").teardown_gutter(tabpage) -- Clear highlights from both buffers state.clear_buffer_highlights(diff.original_bufnr) @@ -84,19 +90,12 @@ local function cleanup_diff(tabpage) vim.w[diff.result_win].codediff_restore = nil end - -- Clear result buffer signs (conflict mode) - if diff.result_bufnr and vim.api.nvim_buf_is_valid(diff.result_bufnr) then - local result_signs_ns = vim.api.nvim_create_namespace("codediff-result-signs") - vim.api.nvim_buf_clear_namespace(diff.result_bufnr, result_signs_ns, 0, -1) - end - -- Clear conflict file tracking (buffers remain, just not tracked) diff.conflict_files = {} -- Clear tab-specific autocmd groups pcall(vim.api.nvim_del_augroup_by_name, "codediff_lifecycle_tab_" .. tabpage) pcall(vim.api.nvim_del_augroup_by_name, "codediff_working_sync_" .. tabpage) - pcall(vim.api.nvim_del_augroup_by_name, "CodeDiffConflictSigns_" .. tabpage) -- Tear down the scroll-sync group for this tab pcall(function() diff --git a/lua/codediff/ui/lifecycle/state.lua b/lua/codediff/ui/lifecycle/state.lua index 908e0dfb..9c938325 100644 --- a/lua/codediff/ui/lifecycle/state.lua +++ b/lua/codediff/ui/lifecycle/state.lua @@ -124,6 +124,7 @@ local function resume_diff(tabpage) -- orphan its registry, leaving codediff's mappings installed on whichever -- pane survived and its saved user mappings unreachable. if not vim.api.nvim_buf_is_valid(diff.original_bufnr) or not vim.api.nvim_buf_is_valid(diff.modified_bufnr) then + require("codediff.ui.conflict").teardown_gutter(tabpage) if diff.keymaps then diff.keymaps:dispose() diff.keymaps = nil @@ -194,7 +195,27 @@ local function resume_diff(tabpage) -- Render with fresh content and (possibly reused) diff result if lines_diff then - if diff.layout == "inline" then + if diff.result_bufnr and diff.merge_base_lines and diff.conflict_blocks then + -- Conflict inputs are two diffs against BASE, not a diff against each + -- other. Restore that same geometry without resetting Result or its + -- tracking extmarks, so accepted edits and gutter colors survive tabs. + local diff_module = require("codediff.core.diff") + local options = require("codediff.config").options.diff + local diff_options = { + max_computation_time_ms = options.max_computation_time_ms, + ignore_trim_whitespace = options.ignore_trim_whitespace, + compute_moves = options.compute_moves, + } + local original_diff = diff_module.compute_diff(diff.merge_base_lines, original_lines, diff_options) + local modified_diff = diff_module.compute_diff(diff.merge_base_lines, modified_lines, diff_options) + if original_diff and modified_diff then + require("codediff.ui.core").render_merge_view(diff.original_bufnr, diff.modified_bufnr, original_diff, modified_diff, diff.merge_base_lines, original_lines, modified_lines) + diff.stored_diff_result = modified_diff + local conflict = require("codediff.ui.conflict") + conflict.attach_gutter(diff.original_win, diff.modified_win) + conflict.refresh(diff) + end + elseif diff.layout == "inline" then local inline_mod = require("codediff.ui.inline") inline_mod.render_inline_diff(diff.modified_bufnr, lines_diff, original_lines, modified_lines) else diff --git a/lua/codediff/ui/view/side_by_side/update.lua b/lua/codediff/ui/view/side_by_side/update.lua index cc8edf26..2ba676ad 100644 --- a/lua/codediff/ui/view/side_by_side/update.lua +++ b/lua/codediff/ui/view/side_by_side/update.lua @@ -133,6 +133,12 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) return false end + -- Retire the old conflict renderer before asynchronous buffer loading starts. + if session.result_bufnr then + require("codediff.ui.conflict").teardown_gutter(tabpage) + auto_refresh.disable_result(session.result_bufnr) + end + -- Disable auto-refresh temporarily auto_refresh.disable(old_original_buf) auto_refresh.disable(old_modified_buf) diff --git a/tests/README.md b/tests/README.md index cbe9ceb2..74fa66bd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -96,6 +96,39 @@ they opened files from, and a watcher left pointing at a deleted path prints | `CODEDIFF_TEST_TIMEOUT` | `300000` | Per-spec timeout in ms; guards against a hung spec stalling CI. | | `NO_COLOR` / `CODEDIFF_TEST_NO_COLOR` | unset | Disable ANSI colors. | +## Screen-grid regressions + +`tests/framework/screen.lua` starts a separate `nvim --embed` and attaches an +RPC UI. Assertions read actual `grid_line` cells and highlight colors, not +extmark metadata or screen functions in the headless test process. It uses +Neovim's bundled MessagePack and libuv, with no external dependencies. + +The conflict gutter has three automatically discovered specs: + +- `ui/conflict/gutter_grid_spec.lua`: hand-authored block shapes from + `fixtures/conflict_gutter.lua`, including empty/filler-only blocks, BOF/EOF, + partial scrolling, unrelated blank rows, multiple blocks and Result + projections. Checks both glyph cells and colors in all three panes under + each focus state. Fixtures go through the production conflict renderer; + expected rows do not call or duplicate the gutter calculator. +- `ui/conflict/gutter_options_spec.lua`: compares ordinary columns against + Neovim's native rendering, including relative/hybrid numbers, folds, wrapped + rows and other signs; checks custom-option ownership and restoration. +- `ui/conflict/gutter_lifecycle_spec.lua`: real Git merges, file/window/tab + transitions, resizing, accept/undo/redo/discard, manual Result edits and + teardown of stale callbacks and invalid sessions. + +Run these like any other spec, for example: + +```bash +nvim --headless --noplugin -u tests/init.lua \ + -c "lua require('tests.framework').run_and_exit('tests/ui/conflict/gutter_grid_spec.lua')" +``` + +Always close the embedded UI in `after_each`, including when an assertion +fails. A grid failure reports the pane/focus context, display row, expected +text and actual text. These are cell-level checks, not font or pixel snapshots. + ## Test Philosophy Focus on **integration points** that C tests cannot validate: @@ -107,4 +140,4 @@ Focus on **integration points** that C tests cannot validate: ## What's NOT Covered ❌ **Diff algorithm** - Validated by C tests in `c-diff-core/tests/` (3,490 lines) -❌ **Visual correctness** - Manual testing required +❌ **Other visual features and third-party UI integrations** - Manual testing unless covered by a dedicated grid spec diff --git a/tests/fixtures/conflict_gutter.lua b/tests/fixtures/conflict_gutter.lua new file mode 100644 index 00000000..3dcfc771 --- /dev/null +++ b/tests/fixtures/conflict_gutter.lua @@ -0,0 +1,118 @@ +-- Hand-authored screen rows for a six-line buffer: one, two, three, four, five, six. +-- These are visual expectations, not output from the gutter calculator. +return { + { + name = "single real position", + range = { start_line = 2, end_line = 3 }, + fillers = {}, + rows = { " one", "[ two", " three", " four", " five", " six" }, + }, + { + name = "one real line and one trailing filler", + range = { start_line = 2, end_line = 3 }, + fillers = { [2] = 1 }, + rows = { " one", "╭─two", "╰─╱╱╱╱", " three", " four", " five", " six" }, + }, + { + name = "one real line and multiple trailing fillers", + range = { start_line = 2, end_line = 3 }, + fillers = { [2] = 2 }, + rows = { " one", "╭─two", "│ ╱╱╱╱", "╰─╱╱╱╱", " three", " four", " five", " six" }, + }, + { + name = "ordinary multiline range", + range = { start_line = 2, end_line = 5 }, + fillers = {}, + rows = { " one", "╭─two", "│ three", "╰─four", " five", " six" }, + }, + { + name = "leading filler", + range = { start_line = 2, end_line = 5 }, + fillers = { [1] = 1 }, + rows = { " one", "╭─╱╱╱╱", "│ two", "│ three", "╰─four", " five", " six" }, + }, + { + name = "interior filler", + range = { start_line = 2, end_line = 5 }, + fillers = { [3] = 2 }, + rows = { " one", "╭─two", "│ three", "│ ╱╱╱╱", "│ ╱╱╱╱", "╰─four", " five", " six" }, + }, + { + name = "trailing filler", + range = { start_line = 2, end_line = 5 }, + fillers = { [4] = 2 }, + rows = { " one", "╭─two", "│ three", "│ four", "│ ╱╱╱╱", "╰─╱╱╱╱", " five", " six" }, + }, + { + name = "empty without filler", + range = { start_line = 3, end_line = 3 }, + fillers = {}, + rows = { " one", " two", " three", " four", " five", " six" }, + }, + { + name = "empty with one filler", + range = { start_line = 3, end_line = 3 }, + fillers = { [2] = 1 }, + rows = { " one", " two", "[ ╱╱╱╱", " three", " four", " five", " six" }, + }, + { + name = "empty with multiple fillers", + range = { start_line = 3, end_line = 3 }, + fillers = { [2] = 2 }, + rows = { " one", " two", "╭─╱╱╱╱", "╰─╱╱╱╱", " three", " four", " five", " six" }, + }, + { + name = "empty with filler on the fallback anchor", + range = { start_line = 3, end_line = 3 }, + fillers = { [3] = 1 }, + rows = { " one", " two", " three", "[ ╱╱╱╱", " four", " five", " six" }, + }, + { + name = "BOF leading filler", + range = { start_line = 1, end_line = 3 }, + fillers = { [0] = 2 }, + rows = { "╭─╱╱╱╱", "│ ╱╱╱╱", "│ one", "╰─two", " three", " four", " five", " six" }, + }, + { + name = "fillers both before and after line one", + range = { start_line = 1, end_line = 3 }, + fillers = { [0] = 2, [1] = 1 }, + rows = { "╭─╱╱╱╱", "│ ╱╱╱╱", "│ one", "│ ╱╱╱╱", "╰─two", " three", " four", " five", " six" }, + }, + { + name = "one filler-only position at BOF", + range = { start_line = 1, end_line = 1 }, + fillers = { [0] = 1 }, + rows = { "[ ╱╱╱╱", " one", " two", " three", " four", " five", " six" }, + }, + { + name = "multiple filler-only positions at BOF", + range = { start_line = 1, end_line = 1 }, + fillers = { [0] = 2 }, + rows = { "╭─╱╱╱╱", "╰─╱╱╱╱", " one", " two", " three", " four", " five", " six" }, + }, + { + name = "unmarked BOF fillers must not shift marked fillers after line one", + range = { start_line = 2, end_line = 3 }, + fillers = { [0] = 2, [1] = 1 }, + rows = { " ╱╱╱╱", " ╱╱╱╱", " one", "╭─╱╱╱╱", "╰─two", " three", " four", " five", " six" }, + }, + { + name = "trailing filler at EOF", + range = { start_line = 6, end_line = 7 }, + fillers = { [6] = 2 }, + rows = { " one", " two", " three", " four", " five", "╭─six", "│ ╱╱╱╱", "╰─╱╱╱╱" }, + }, + { + name = "empty projection at EOF", + range = { start_line = 7, end_line = 7 }, + fillers = { [6] = 2 }, + rows = { " one", " two", " three", " four", " five", " six", "╭─╱╱╱╱", "╰─╱╱╱╱" }, + }, + { + name = "unrelated fillers outside the block stay blank", + range = { start_line = 3, end_line = 5 }, + fillers = { [1] = 1, [5] = 1 }, + rows = { " one", " ╱╱╱╱", " two", "╭─three", "╰─four", " five", " ╱╱╱╱", " six" }, + }, +} diff --git a/tests/framework/screen.lua b/tests/framework/screen.lua new file mode 100644 index 00000000..ca69ca2c --- /dev/null +++ b/tests/framework/screen.lua @@ -0,0 +1,220 @@ +-- An embedded Neovim UI for assertions on rendered cells, not extmark metadata. +-- Uses Neovim's bundled MessagePack and libuv; no Python or external UI dependency. +local Screen = {} +Screen.__index = Screen + +local uv = vim.uv or vim.loop + +local function blank_row(width) + local row = {} + for col = 1, width do + row[col] = { " ", 0 } + end + return row +end + +function Screen:_redraw(events) + for _, event in ipairs(events) do + local name = event[1] + for i = 2, #event do + local args = event[i] + if name == "grid_resize" and args[1] == 1 then + self.width, self.height = args[2], args[3] + self.grid = {} + for row = 1, self.height do + self.grid[row] = blank_row(self.width) + end + elseif name == "grid_clear" and args[1] == 1 then + for row = 1, self.height do + self.grid[row] = blank_row(self.width) + end + elseif name == "grid_line" and args[1] == 1 then + local row, col, highlight = self.grid[args[2] + 1], args[3] + 1, 0 + for _, cell in ipairs(args[4]) do + highlight = cell[2] or highlight + for _ = 1, cell[3] or 1 do + row[col] = { cell[1], highlight } + col = col + 1 + end + end + elseif name == "grid_scroll" and args[1] == 1 then + local top, bottom, left, right, rows, cols = unpack(args, 2) + local old = {} + for row = top + 1, bottom do + old[row] = {} + for col = left + 1, right do + old[row][col] = self.grid[row][col] + end + end + for row = top + 1, bottom do + for col = left + 1, right do + local source = old[row + rows] + self.grid[row][col] = source and source[col + cols] or { " ", 0 } + end + end + elseif name == "hl_attr_define" then + self.highlights[args[1]] = args[2] + elseif name == "flush" then + self.flushes = self.flushes + 1 + end + end + end +end + +function Screen.new(width, height) + local self = setmetatable({ next_id = 0, responses = {}, grid = {}, highlights = {}, flushes = 0, stderr = "" }, Screen) + self.stdin, self.stdout, self.errpipe = uv.new_pipe(false), uv.new_pipe(false), uv.new_pipe(false) + local err + self.process, err = uv.spawn(vim.v.progpath, { + args = { "--embed", "--noplugin", "-u", "tests/init.lua", "-i", "NONE" }, + cwd = vim.fn.getcwd(), + stdio = { self.stdin, self.stdout, self.errpipe }, + }, function(code) + self.exit_code = code + end) + if not self.process then + self:close() + error("Cannot start embedded Neovim: " .. tostring(err)) + end + + local unpacker = vim.mpack.Unpacker() + self.stdout:read_start(function(read_error, data) + if read_error then + self.error = read_error + end + if not data then + return + end + local ok, decode_error = pcall(function() + local pos = 1 + while pos <= #data do + local message + message, pos = unpacker(data, pos) + if message then + if message[1] == 1 then + self.responses[message[2]] = message + elseif message[1] == 2 and message[2] == "redraw" then + self:_redraw(message[3]) + end + end + end + end) + if not ok then + self.error = decode_error + end + end) + self.errpipe:read_start(function(_, data) + self.stderr = self.stderr .. (data or "") + end) + + local ok, start_error = pcall(function() + self:request("nvim_ui_attach", { width or 100, height or 30, { rgb = true, ext_linegrid = true } }) + self:exec([[ + vim.o.showtabline = 0 + vim.o.laststatus = 0 + vim.o.showmode = false + vim.o.ruler = false + vim.o.shortmess = 'atIF' + vim.o.scrolloff = 0 + vim.o.sidescrolloff = 0 + ]]) + self:flush() + end) + if not ok then + self:close() + error(start_error) + end + return self +end + +function Screen:request(method, args) + self.next_id = self.next_id + 1 + local id = self.next_id + self.stdin:write(vim.mpack.encode({ 0, id, method, args or {} })) + local ready = vim.wait(10000, function() + return self.responses[id] ~= nil or self.error ~= nil or self.exit_code ~= nil + end, 1) + local response = self.responses[id] + self.responses[id] = nil + assert(ready and response and not self.error, string.format("RPC %s failed: %s\n%s", method, tostring(self.error or self.exit_code or "timeout"), self.stderr)) + assert(response[3] == vim.NIL, string.format("RPC %s: %s", method, vim.inspect(response[3]))) + if response[4] ~= vim.NIL then + return response[4] + end +end + +function Screen:exec(code, args) + return self:request("nvim_exec_lua", { code, args or {} }) +end + +function Screen:command(command) + return self:request("nvim_command", { command }) +end + +function Screen:input(keys) + return self:request("nvim_input", { keys }) +end + +function Screen:flush() + local before = self.flushes + self:command("redraw!") + self:request("nvim_eval", { "0" }) + assert( + vim.wait(5000, function() + return self.flushes > before + end, 1), + "No UI flush after redraw" + ) +end + +function Screen:wait_for(predicate, message) + assert(vim.wait(10000, predicate, 10), (message or "Embedded Neovim did not become ready") .. "\n" .. self.stderr) + self:flush() +end + +-- Screen coordinates here are one-based, including UI columns (not UTF-8 bytes). +function Screen:text(row, col, width) + local text = {} + for index = col, col + width - 1 do + text[#text + 1] = self.grid[row][index][1] + end + return table.concat(text) +end + +function Screen:highlight(row, col) + return self.highlights[self.grid[row][col][2]] or {} +end + +function Screen:foreground(row, col) + return self:highlight(row, col).foreground +end + +function Screen:expect_rows(row, col, expected, label) + for index, text in ipairs(expected) do + assert.equals(text, self:text(row + index - 1, col, vim.fn.strdisplaywidth(text)), string.format("%s, display row %d", label or "screen", index)) + end +end + +function Screen:close() + if self.process and not self.process:is_closing() then + if self.exit_code == nil then + self.stdin:write(vim.mpack.encode({ 0, self.next_id + 1, "nvim_command", { "qa!" } })) + if not vim.wait(2000, function() + return self.exit_code ~= nil + end, 10) then + self.process:kill("sigterm") + vim.wait(2000, function() + return self.exit_code ~= nil + end, 10) + end + end + self.process:close() + end + for _, pipe in ipairs({ self.stdin, self.stdout, self.errpipe }) do + if not pipe:is_closing() then + pipe:close() + end + end +end + +return Screen diff --git a/tests/module_loading_spec.lua b/tests/module_loading_spec.lua index dc81d410..84ce167f 100644 --- a/tests/module_loading_spec.lua +++ b/tests/module_loading_spec.lua @@ -349,9 +349,9 @@ describe("Conflict submodules", function() assert.is_not_nil(mod) end) - it("loads signs module", function() - local ok, mod = pcall(require, "codediff.ui.conflict.signs") - assert.is_true(ok, "Failed to require codediff.ui.conflict.signs") + it("loads gutter module", function() + local ok, mod = pcall(require, "codediff.ui.conflict.gutter") + assert.is_true(ok, "Failed to require codediff.ui.conflict.gutter") assert.is_not_nil(mod) end) @@ -423,16 +423,23 @@ describe("Conflict submodules", function() assert.is_function(mod.navigate_prev_conflict) end) - it("signs exports expected functions", function() - local mod = require("codediff.ui.conflict.signs") - assert.is_function(mod.refresh_all_conflict_signs) - assert.is_function(mod.setup_sign_refresh_autocmd) + it("gutter modules export expected functions", function() + local gutter = require("codediff.ui.conflict.gutter") + local compute = require("codediff.ui.conflict.gutter.compute") + local signcolumn = require("codediff.ui.conflict.gutter.signcolumn") + local statuscolumn = require("codediff.ui.conflict.gutter.statuscolumn") + assert.is_function(gutter.refresh) + assert.is_function(gutter.setup_refresh_autocmd) + assert.is_function(compute.visual_positions) + assert.is_function(compute.compute_markers) + assert.is_function(signcolumn.render) + assert.is_function(statuscolumn.render) end) it("init facade delegates expected functions", function() local mod = require("codediff.ui.conflict") local tracking = require("codediff.ui.conflict.tracking") - local signs = require("codediff.ui.conflict.signs") + local gutter = require("codediff.ui.conflict.gutter") local resolution = require("codediff.ui.conflict.resolution") local navigation = require("codediff.ui.conflict.navigation") local keymaps = require("codediff.ui.conflict.keymaps") @@ -443,8 +450,8 @@ describe("Conflict submodules", function() assert.is_function(mod.navigate_next_conflict) assert.equal(tracking.run_repeatable_action, mod.run_repeatable_action) assert.equal(tracking.initialize_tracking, mod.initialize_tracking) - assert.equal(signs.refresh_all_conflict_signs, mod.refresh_all_conflict_signs) - assert.equal(signs.setup_sign_refresh_autocmd, mod.setup_sign_refresh_autocmd) + assert.equal(gutter.refresh, mod.refresh) + assert.equal(gutter.setup_refresh_autocmd, mod.setup_refresh_autocmd) assert.equal(resolution.accept_incoming, mod.accept_incoming) assert.equal(resolution.accept_current, mod.accept_current) assert.equal(resolution.accept_both, mod.accept_both) diff --git a/tests/ui/conflict/conflict_signs_spec.lua b/tests/ui/conflict/conflict_signs_spec.lua index fec1f09b..ee26223a 100644 --- a/tests/ui/conflict/conflict_signs_spec.lua +++ b/tests/ui/conflict/conflict_signs_spec.lua @@ -58,7 +58,7 @@ describe("conflict signs", function() end) it("changes input and Result signs after accepting one side", function() - conflict.refresh_all_conflict_signs(session) + conflict.refresh(session) assert.equals("CodeDiffConflictSign", sign_highlight(session.original_bufnr, highlights.ns_conflict)) assert.equals("CodeDiffConflictSign", sign_highlight(session.modified_bufnr, highlights.ns_conflict)) assert.equals("CodeDiffConflictSign", sign_highlight(session.result_bufnr, tracking.result_signs_ns)) diff --git a/tests/ui/conflict/gutter_grid_spec.lua b/tests/ui/conflict/gutter_grid_spec.lua new file mode 100644 index 00000000..bb493f82 --- /dev/null +++ b/tests/ui/conflict/gutter_grid_spec.lua @@ -0,0 +1,241 @@ +local Screen = require("tests.framework.screen") +local gutter = require("tests.ui.conflict.gutter_helpers") +local cases = require("tests.fixtures.conflict_gutter") + +describe("conflict block gutter on the screen grid", function() + local screen + + after_each(function() + if screen then + screen:close() + screen = nil + end + end) + + for index, case in ipairs(cases) do + it(case.name .. " in all panes under each focus state", function() + screen = Screen.new(120, 24) + local panes = gutter.open_block(screen, index) + local expected = gutter.block_rows(case) + + for _, focus in ipairs({ "original", "modified", "result" }) do + screen:exec("vim.api.nvim_set_current_win(...)", { panes[focus] }) + screen:flush() + for _, side in ipairs({ "original", "modified", "result" }) do + gutter.expect_pane(screen, panes[side], expected[side], case.name .. ": " .. side .. " while focused on " .. focus, panes.foreground) + end + end + end) + end + + it("keeps BOF offsets correct when only part of the leading filler is visible", function() + screen = Screen.new(120, 24) + local panes = gutter.open_block(screen, 13) + screen:exec( + [[ + local win = ... + vim.api.nvim_set_current_win(win) + vim.fn.winrestview({ topline = 1, topfill = 1 }) + ]], + { panes.original } + ) + screen:flush() + gutter.expect_pane(screen, panes.original, { + "│ ╱╱╱╱", + "│ one", + "│ ╱╱╱╱", + "╰─two", + " three", + " four", + " five", + " six", + }, "partially scrolled BOF", panes.foreground) + end) + + it("keeps after-line-one markers correct when all BOF filler is scrolled out", function() + screen = Screen.new(120, 24) + local panes = gutter.open_block(screen, 13) + screen:exec( + [[ + vim.api.nvim_set_current_win(...) + vim.fn.winrestview({ topline = 1, topfill = 0 }) + ]], + { panes.original } + ) + screen:flush() + gutter.expect_pane(screen, panes.original, { + "│ one", + "│ ╱╱╱╱", + "╰─two", + " three", + " four", + " five", + " six", + }, "hidden BOF", panes.foreground) + end) + + for _, options in ipairs({ + { name = "number and fold columns", number = true, relativenumber = true, numberwidth = 6, foldcolumn = "2", signcolumn = "yes" }, + { name = "multiple sign slots", number = true, relativenumber = false, numberwidth = 4, foldcolumn = "0", signcolumn = "yes:2" }, + { name = "signs in the number column", number = true, relativenumber = true, numberwidth = 4, foldcolumn = "0", signcolumn = "number" }, + { name = "hidden signs", number = true, relativenumber = true, numberwidth = 4, foldcolumn = "0", signcolumn = "no" }, + }) do + it("changes only filler sign cells with " .. options.name, function() + screen = Screen.new(120, 24) + local panes = gutter.open_block(screen, 3) + local rect = screen:exec( + [[ + local win, options = ... + vim.api.nvim_set_current_win(win) + require('codediff.ui.conflict').detach_gutter(win) + for name, value in pairs(options) do + if name ~= 'name' then vim.wo[win][name] = value end + end + local p = vim.api.nvim_win_get_position(win) + return { p[1] + 1, p[2] + 1, vim.api.nvim_win_get_width(win), vim.fn.getwininfo(win)[1].textoff } + ]], + { panes.original, options } + ) + screen:flush() + local expected = {} + for row = 1, 8 do + expected[row] = screen:text(rect[1] + row - 1, rect[2], rect[3]) + end + if options.signcolumn ~= "no" then + local sign_col = options.signcolumn == "number" and rect[4] - 3 or tonumber(options.foldcolumn) + for row, glyph in pairs({ [3] = "│ ", [4] = "╰─" }) do + expected[row] = vim.fn.strcharpart(expected[row], 0, sign_col) .. glyph .. vim.fn.strcharpart(expected[row], sign_col + 2) + end + end + screen:exec( + [[ + local win, tab = ... + local conflict = require('codediff.ui.conflict') + conflict.attach_gutter(win) + conflict.refresh(require('codediff.ui.lifecycle').get_session(tab)) + ]], + { panes.original, panes.tab } + ) + screen:flush() + screen:expect_rows(rect[1], rect[2], expected, options.name) + end) + end + + it("composes separate blocks and removes one without damaging the other", function() + screen = Screen.new(120, 24) + local panes = gutter.open_block(screen, 3) + screen:exec( + [[ + local s = require('codediff.ui.lifecycle').get_session(...) + s.conflict_blocks[2] = { + base_range = { start_line = 7, end_line = 8 }, result_range = { start_line = 7, end_line = 8 }, + output1_range = { start_line = 5, end_line = 6 }, output2_range = { start_line = 7, end_line = 8 }, + } + local conflict = require('codediff.ui.conflict') + conflict.initialize_tracking(s.result_bufnr, s.conflict_blocks) + conflict.refresh(s) + ]], + { panes.tab } + ) + screen:flush() + local expected = gutter.block_rows(cases[3]) + expected.original = { " one", "╭─two", "│ ╱╱╱╱", "╰─╱╱╱╱", " three", " four", "[ five", " six" } + expected.modified[9] = "[ five" + expected.result[7] = "[ base seven" + for _, side in ipairs({ "original", "modified", "result" }) do + gutter.expect_pane(screen, panes[side], expected[side], "two blocks: " .. side, panes.foreground) + end + screen:exec( + [[ + local tab = ... + local lifecycle = require('codediff.ui.lifecycle') + local s = lifecycle.get_session(tab) + lifecycle.set_conflict_blocks(tab, { s.conflict_blocks[2] }) + require('codediff.ui.conflict').refresh(s) + ]], + { panes.tab } + ) + screen:flush() + gutter.expect_pane(screen, panes.original, { + " one", + " two", + " ╱╱╱╱", + " ╱╱╱╱", + " three", + " four", + "[ five", + " six", + }, "only the second block remains", panes.foreground) + end) + + it("renders no Result marker for an empty Result projection", function() + screen = Screen.new(120, 24) + local panes = gutter.open_block(screen, 3) + screen:exec( + [[ + local s = require('codediff.ui.lifecycle').get_session(...) + s.conflict_blocks[1].result_range = { start_line = 4, end_line = 4 } + local conflict = require('codediff.ui.conflict') + conflict.initialize_tracking(s.result_bufnr, s.conflict_blocks) + conflict.refresh(s) + ]], + { panes.tab } + ) + screen:flush() + gutter.expect_pane(screen, panes.result, { + " base one", + " base two", + " base three", + " base four", + " base five", + " base six", + " base seven", + }, "empty Result projection") + gutter.expect_pane(screen, panes.original, cases[3].rows, "nonempty input projection", panes.foreground) + end) + + it("uses updated highlight colors on real and filler markers", function() + screen = Screen.new(120, 24) + local panes = gutter.open_block(screen, 3) + screen:exec("vim.api.nvim_set_hl(0, 'CodeDiffConflictSign', { fg = 0x12ab34, bg = 0x345678, bold = true })") + screen:flush() + local expected = gutter.block_rows(cases[3]) + for _, side in ipairs({ "original", "modified", "result" }) do + gutter.expect_pane(screen, panes[side], expected[side], "updated highlight: " .. side, { foreground = 0x12ab34, background = 0x345678, bold = true }) + end + end) + + it("clears a removed block on both real and filler rows", function() + screen = Screen.new(120, 24) + local panes = gutter.open_block(screen, 3) + screen:exec( + [[ + local tab = ... + local lifecycle = require('codediff.ui.lifecycle') + lifecycle.set_conflict_blocks(tab, {}) + require('codediff.ui.conflict').refresh(lifecycle.get_session(tab)) + ]], + { panes.tab } + ) + screen:flush() + gutter.expect_pane(screen, panes.original, { + " one", + " two", + " ╱╱╱╱", + " ╱╱╱╱", + " three", + " four", + " five", + " six", + }, "removed block") + gutter.expect_pane(screen, panes.result, { + " base one", + " base two", + " base three", + " base four", + " base five", + " base six", + " base seven", + }, "removed Result block") + end) +end) diff --git a/tests/ui/conflict/gutter_helpers.lua b/tests/ui/conflict/gutter_helpers.lua new file mode 100644 index 00000000..d77d9049 --- /dev/null +++ b/tests/ui/conflict/gutter_helpers.lua @@ -0,0 +1,254 @@ +local M = {} + +-- Build a block with deliberately different buffer coordinates in each pane. +-- Only the public conflict entry points render it; no calculator/backend calls. +function M.open_block(screen, case_index) + return screen:exec( + [[ + local case_index = ... + local case = require('tests.fixtures.conflict_gutter')[case_index] + local conflict = require('codediff.ui.conflict') + local lifecycle = require('codediff.ui.lifecycle') + local path = require('codediff.core.path') + local filler = require('codediff.ui.filler') + require('codediff').setup({ diff = { filler_text = '╱' } }) + local words = { 'one', 'two', 'three', 'four', 'five', 'six' } + local function buffer(lines) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + return buf + end + local function window(buf, split) + if split then vim.cmd('rightbelow vsplit') end + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, buf) + for name, value in pairs({ + number = false, relativenumber = false, foldcolumn = '0', + signcolumn = 'yes', statuscolumn = '', wrap = false, + cursorline = false, cursorcolumn = false, winbar = '', + }) do + vim.wo[win][name] = value + end + return win + end + local original_buf = buffer(words) + local original_win = window(original_buf) + local modified_lines = { 'prefix', 'prefix' } + vim.list_extend(modified_lines, words) + local modified_buf = buffer(modified_lines) + local modified_win = window(modified_buf, true) + local base_lines = { 'base one', 'base two', 'base three', 'base four', 'base five', 'base six', 'base seven' } + local result_buf = buffer(base_lines) + local result_win = window(result_buf, true) + local tab = vim.api.nvim_get_current_tabpage() + lifecycle.create_session(tab, { + conflict = true, original = path.make_ref('', nil), modified = path.make_ref('', nil), + }, { + original_bufnr = original_buf, modified_bufnr = modified_buf, + original_win = original_win, modified_win = modified_win, + }) + lifecycle.set_result(tab, result_buf, result_win) + lifecycle.set_result_base_lines(tab, base_lines) + local blocks = {{ + base_range = { start_line = 4, end_line = 6 }, + result_range = { start_line = 4, end_line = 6 }, + output1_range = vim.deepcopy(case.range), + output2_range = { start_line = case.range.start_line + 2, end_line = case.range.end_line + 2 }, + }} + lifecycle.set_conflict_blocks(tab, blocks) + for anchor, count in pairs(case.fillers) do + filler.place(original_buf, anchor - 1, count) + filler.place(modified_buf, anchor + 1, count) + end + conflict.initialize_tracking(result_buf, blocks) + conflict.attach_gutter(original_win, modified_win) + conflict.setup_refresh_autocmd(tab, result_buf) + conflict.refresh(lifecycle.get_session(tab)) + vim.api.nvim_win_call(original_win, function() + vim.fn.winrestview({ topline = 1, topfill = case.fillers[0] or 0 }) + end) + return { + tab = tab, original = original_win, modified = modified_win, result = result_win, + foreground = vim.api.nvim_get_hl(0, { name = 'CodeDiffConflictSign', link = false }).fg, + } + ]], + { case_index } + ) +end + +function M.expect_pane(screen, win, rows, label, highlight) + local position = screen:exec( + [[ + local win = ... + local position = vim.api.nvim_win_get_position(win) + if vim.wo[win].winbar ~= '' then position[1] = position[1] + 1 end + return position + ]], + { win } + ) + local row, col = position[1] + 1, position[2] + 1 + screen:expect_rows(row, col, rows, label) + if highlight then + local expected = type(highlight) == "table" and highlight or { foreground = highlight } + for index, text in ipairs(rows) do + local first = vim.fn.strcharpart(text, 0, 1) + if first == "[" or first == "╭" or first == "│" or first == "╰" then + for offset = 0, 1 do + local actual = screen:highlight(row + index - 1, col + offset) + for attr, value in pairs(expected) do + assert.equals(value, actual[attr], label .. ": marker " .. attr .. " on display row " .. index) + end + end + end + end + end +end + +function M.block_rows(case) + local modified = { " prefix", " prefix" } + vim.list_extend(modified, case.rows) + return { + original = case.rows, + modified = modified, + result = { " base one", " base two", " base three", "╭─base four", "╰─base five", " base six", " base seven" }, + } +end + +-- A small real merge with an interior filler in incoming, plus a second file +-- whose filler is on the opposite side. Expectations are authored below. +function M.create_repo() + local repo = require("tests.helpers").create_temp_git_repo() + repo.write_file("conf.txt", { "before", "base1", "base2", "base3", "after", "tail" }) + repo.write_file("other.txt", { "beforeB", "baseB", "afterB" }) + repo.write_file("deleted.txt", { "deleted one", "deleted two" }) + repo.git("add -A") + repo.git("commit -m base") + repo.git("checkout -b incoming") + repo.write_file("conf.txt", { "before", "base1", "THEIRS2", "THEIRS3", "after", "tail" }) + repo.write_file("other.txt", { "beforeB", "THEIRS_B", "extraB", "afterB" }) + repo.git("commit -am incoming") + repo.git("checkout main") + repo.write_file("conf.txt", { "before", "OURS1", "inserted", "OURS2", "base3", "after", "tail" }) + repo.write_file("other.txt", { "beforeB", "OURS_B", "afterB" }) + repo.git("commit -am current") + local _, code = repo.git("merge incoming --no-edit") + assert.equals(1, code, "fixture must produce a merge conflict") + repo.write_file("fresh.txt", { "fresh one", "fresh two" }) + repo.write_file("plain-left.txt", { "before", "same", "keep", "after" }) + repo.write_file("plain-right.txt", { "before", "same", "keep", "inserted", "after" }) + return repo +end + +M.merge_rows = { + original = { " before", "╭─base1", "│ THEIRS2", "│ ╱╱╱╱", "╰─THEIRS3", " after", " tail" }, + modified = { " before", "╭─OURS1", "│ inserted", "│ OURS2", "╰─base3", " after", " tail" }, + result = { " before", "╭─base1", "│ base2", "╰─base3", " after", " tail" }, +} + +M.other_merge_rows = { + original = { " beforeB", "╭─THEIRS_B", "╰─extraB", " afterB" }, + modified = { " beforeB", "╭─╱╱╱╱", "╰─OURS_B", " afterB" }, + result = { " beforeB", "[ baseB", " afterB" }, +} + +function M.open_merge(screen, repo) + screen:exec( + [[ + local root = ... + require('codediff').setup({ diff = { filler_text = '╱', jump_to_first_change = false } }) + vim.o.number = false + vim.o.relativenumber = false + vim.o.foldcolumn = '0' + local path = require('codediff.core.path') + _G.gutter_test_config = function(file) + return { + git_root = root, conflict = true, + original = path.make_ref(file or 'conf.txt', root), + modified = path.make_ref(file or 'conf.txt', root), + original_revision = ':3', modified_revision = ':2', + } + end + require('codediff.ui.view').create(gutter_test_config(), '', function() + _G.gutter_test_ready = true + _G.gutter_test_tab = vim.api.nvim_get_current_tabpage() + end) + ]], + { repo.dir } + ) + screen:wait_for(function() + return screen:exec("return _G.gutter_test_ready == true") + end, "merge view did not open") + return M.panes(screen) +end + +function M.panes(screen) + return screen:exec([[ + local session = require('codediff.ui.lifecycle').get_session(gutter_test_tab) + return { + tab = gutter_test_tab, original = session.original_win, modified = session.modified_win, + result = session.result_win, result_buf = session.result_bufnr, + } + ]]) +end + +function M.return_to_merge(screen, file) + screen:exec( + [[ + local file = ... + require('codediff.ui.view').update(gutter_test_tab, gutter_test_config(file), false) + ]], + { file or "conf.txt" } + ) + screen:wait_for(function() + return screen:exec( + [[ + local file = ... + local s = require('codediff.ui.lifecycle').get_session(gutter_test_tab) + return s and s.stored_diff_result ~= nil and s.result_win ~= nil + and vim.api.nvim_buf_get_name(s.result_bufnr):sub(-#file) == file + ]], + { file or "conf.txt" } + ) + end, "merge view did not return") + return M.panes(screen) +end + +function M.open_plain(screen, repo) + screen:exec( + [[ + local root = ... + local path = require('codediff.core.path') + require('codediff.ui.view').update(gutter_test_tab, { + original = path.make_ref(root .. '/plain-left.txt', nil), + modified = path.make_ref(root .. '/plain-right.txt', nil), + }, false) + ]], + { repo.dir } + ) + screen:wait_for(function() + return screen:exec([[ + local s = require('codediff.ui.lifecycle').get_session(gutter_test_tab) + return s and s.stored_diff_result ~= nil and s.result_win == nil + ]]) + end, "plain diff did not open") + return M.panes(screen) +end + +function M.expect_no_conflict_markers(screen, win) + local rect = screen:exec( + [[ + local win = ... + local p = vim.api.nvim_win_get_position(win) + return { p[1] + 1, p[2] + 1, vim.api.nvim_win_get_height(win), vim.api.nvim_win_get_width(win) } + ]], + { win } + ) + for row = rect[1], rect[1] + rect[3] - 1 do + local text = screen:text(row, rect[2], rect[4]) + for _, glyph in ipairs({ "╭", "│", "╰", "[" }) do + assert.is_nil(text:find(glyph, 1, true), "unexpected conflict marker in window " .. win .. ": " .. text) + end + end +end + +return M diff --git a/tests/ui/conflict/gutter_lifecycle_spec.lua b/tests/ui/conflict/gutter_lifecycle_spec.lua new file mode 100644 index 00000000..4603dbc3 --- /dev/null +++ b/tests/ui/conflict/gutter_lifecycle_spec.lua @@ -0,0 +1,243 @@ +local Screen = require("tests.framework.screen") +local gutter = require("tests.ui.conflict.gutter_helpers") + +describe("conflict gutter lifecycle on the screen grid", function() + local screen, repo, panes + + before_each(function() + repo = gutter.create_repo() + screen = Screen.new(120, 40) + panes = gutter.open_merge(screen, repo) + end) + + after_each(function() + if screen then + screen:close() + screen = nil + end + if repo then + repo.cleanup() + repo = nil + end + end) + + local function expect_merge(rows, colors) + rows = rows or gutter.merge_rows + for _, focus in ipairs({ "original", "modified", "result" }) do + screen:exec("vim.api.nvim_set_current_win(...)", { panes[focus] }) + screen:flush() + for _, side in ipairs({ "original", "modified", "result" }) do + gutter.expect_pane(screen, panes[side], rows[side], side .. " while focused on " .. focus, colors and colors[side]) + end + end + end + + local function colors(original, modified, result) + return screen:exec( + [[ + local names = ... + local values = {} + for side, name in pairs(names) do + values[side] = vim.api.nvim_get_hl(0, { name = 'CodeDiffConflictSign' .. name, link = false }).fg + end + return values + ]], + { { original = original, modified = modified, result = result } } + ) + end + + it("renders a real merge with the full block shape in all three panes", function() + expect_merge(nil, colors("", "", "")) + end) + + it("removes conflict markers when the same windows show an ordinary diff", function() + panes = gutter.open_plain(screen, repo) + gutter.expect_no_conflict_markers(screen, panes.original) + gutter.expect_no_conflict_markers(screen, panes.modified) + end) + + it("survives repeated ordinary-diff round trips", function() + for _ = 1, 3 do + panes = gutter.open_plain(screen, repo) + gutter.expect_no_conflict_markers(screen, panes.original) + gutter.expect_no_conflict_markers(screen, panes.modified) + panes = gutter.return_to_merge(screen) + expect_merge() + end + end) + + it("restores the surviving window's option in single-file mode", function() + screen:exec( + [[ + require('codediff.ui.view.side_by_side').show_untracked_file(gutter_test_tab, ...) + ]], + { repo.path("fresh.txt") } + ) + assert.equals("", screen:exec("return vim.wo[...].statuscolumn", { panes.modified })) + end) + + it("rebinds a recreated incoming pane after an untracked file", function() + for _ = 1, 2 do + screen:exec( + [[ + require('codediff.ui.view.side_by_side').show_untracked_file(gutter_test_tab, ...) + ]], + { repo.path("fresh.txt") } + ) + panes = gutter.return_to_merge(screen) + expect_merge() + end + end) + + it("rebinds a recreated current pane after a deleted file", function() + panes = gutter.return_to_merge(screen, "other.txt") + expect_merge(gutter.other_merge_rows) + screen:exec( + [[ + local root = ... + require('codediff.ui.view.side_by_side').show_deleted_file(gutter_test_tab, root, 'deleted.txt', root .. '/deleted.txt', 'unstaged') + ]], + { repo.dir } + ) + panes = gutter.return_to_merge(screen, "other.txt") + expect_merge(gutter.other_merge_rows) + end) + + it("replaces marker maps when switching between two conflict files", function() + panes = gutter.return_to_merge(screen, "other.txt") + expect_merge(gutter.other_merge_rows) + panes = gutter.return_to_merge(screen) + expect_merge() + end) + + it("restores conflict geometry and markers after leaving and returning to the tab", function() + screen:command("tabnew") + screen:exec("vim.api.nvim_set_current_tabpage(gutter_test_tab)") + screen:wait_for(function() + return screen:exec("return not require('codediff.ui.lifecycle').get_session(gutter_test_tab).suspended") + end) + expect_merge() + end) + + it("keeps both panes correct after resizing the attached UI", function() + screen:request("nvim_ui_try_resize", { 100, 36 }) + screen:flush() + expect_merge() + end) + + it("updates shapes and colors on accept, undo, redo and discard", function() + screen:exec([[ + local s = require('codediff.ui.lifecycle').get_session(gutter_test_tab) + vim.api.nvim_set_current_win(s.modified_win) + vim.api.nvim_win_set_cursor(s.modified_win, { 2, 0 }) + assert(require('codediff.ui.conflict').accept_current(gutter_test_tab)) + ]]) + local resolved = vim.deepcopy(gutter.merge_rows) + resolved.result = { " before", "╭─OURS1", "│ inserted", "│ OURS2", "╰─base3", " after", " tail" } + expect_merge(resolved, colors("Rejected", "Accepted", "Resolved")) + + screen:exec("vim.api.nvim_set_current_win(...)", { panes.result }) + screen:input("u") + screen:wait_for(function() + return screen:exec("return vim.api.nvim_buf_line_count(...) == 6", { panes.result_buf }) + end, "undo did not restore the seed") + expect_merge(nil, colors("", "", "")) + + screen:exec("vim.api.nvim_set_current_win(...)", { panes.result }) + screen:input("") + screen:wait_for(function() + return screen:exec("return vim.api.nvim_buf_line_count(...) == 7", { panes.result_buf }) + end, "redo did not restore the resolution") + expect_merge(resolved, colors("Rejected", "Accepted", "Resolved")) + + screen:exec([[ + local s = require('codediff.ui.lifecycle').get_session(gutter_test_tab) + vim.api.nvim_set_current_win(s.modified_win) + vim.api.nvim_win_set_cursor(s.modified_win, { 2, 0 }) + assert(require('codediff.ui.conflict').discard(gutter_test_tab)) + ]]) + expect_merge(nil, colors("", "", "")) + end) + + it("preserves resolved Result content and colors across a tab round trip", function() + screen:exec([[ + local s = require('codediff.ui.lifecycle').get_session(gutter_test_tab) + vim.api.nvim_set_current_win(s.modified_win) + vim.api.nvim_win_set_cursor(s.modified_win, { 2, 0 }) + assert(require('codediff.ui.conflict').accept_current(gutter_test_tab)) + vim.cmd('tabnew') + vim.api.nvim_set_current_tabpage(gutter_test_tab) + ]]) + screen:wait_for(function() + return screen:exec("return not require('codediff.ui.lifecycle').get_session(gutter_test_tab).suspended") + end) + local rows = vim.deepcopy(gutter.merge_rows) + rows.result = { " before", "╭─OURS1", "│ inserted", "│ OURS2", "╰─base3", " after", " tail" } + expect_merge(rows, colors("Rejected", "Accepted", "Resolved")) + end) + + it("removes Result markers when manual editing empties its tracked range", function() + screen:exec("vim.api.nvim_set_current_win(...)", { panes.result }) + screen:input("2G3dd") + screen:wait_for(function() + return screen:exec("return vim.api.nvim_buf_line_count(...) == 3", { panes.result_buf }) + end) + local rows = vim.deepcopy(gutter.merge_rows) + rows.result = { "before", "after", "tail" } + expect_merge(rows, colors("Resolved", "Resolved", "Resolved")) + end) + + it("refreshes marker colors after a manual Result edit", function() + screen:exec("vim.api.nvim_set_current_win(...); vim.api.nvim_win_set_cursor(0, { 2, 0 })", { panes.result }) + screen:input("ccMANUAL") + screen:wait_for(function() + return screen:exec("return vim.api.nvim_buf_get_lines(..., 1, 2, false)[1] == 'MANUAL'", { panes.result_buf }) + end) + local rows = vim.deepcopy(gutter.merge_rows) + rows.result[2] = "╭─MANUAL" + expect_merge(rows, colors("Resolved", "Resolved", "Resolved")) + end) + + it("does not run the old Result refresh against a retargeted session", function() + local old_result = panes.result_buf + panes = gutter.open_plain(screen, repo) + screen:exec( + [[ + local buf = ... + vim.api.nvim_buf_set_lines(buf, 0, 1, false, { 'changed after leaving conflict' }) + local ok, err = pcall(vim.api.nvim_exec_autocmds, 'TextChanged', { buffer = buf }) + assert(ok, tostring(err)) + ]], + { old_result } + ) + screen:flush() + gutter.expect_no_conflict_markers(screen, panes.original) + gutter.expect_no_conflict_markers(screen, panes.modified) + end) + + it("releases the surviving pane when a suspended session loses an input buffer", function() + local value = screen:exec([[ + local s = require('codediff.ui.lifecycle').get_session(gutter_test_tab) + local state = require('codediff.ui.lifecycle.state') + state.suspend_diff(gutter_test_tab) + vim.api.nvim_buf_delete(s.original_bufnr, { force = true }) + state.resume_diff(gutter_test_tab) + return vim.wo[s.modified_win].statuscolumn + ]]) + assert.equals("", value) + end) + + it("restores statuscolumns when the session is explicitly cleaned up", function() + screen:exec("require('codediff.ui.lifecycle').cleanup(gutter_test_tab)") + assert.is_nil(screen:exec("return require('codediff.ui.lifecycle').get_session(gutter_test_tab)")) + local columns = screen:exec([[ + local values = {} + for _, win in ipairs(vim.api.nvim_list_wins()) do values[#values + 1] = vim.wo[win].statuscolumn end + return values + ]]) + assert.is_true(#columns > 0) + for _, value in ipairs(columns) do + assert.equals("", value) + end + end) +end) diff --git a/tests/ui/conflict/gutter_options_spec.lua b/tests/ui/conflict/gutter_options_spec.lua new file mode 100644 index 00000000..91c35f0b --- /dev/null +++ b/tests/ui/conflict/gutter_options_spec.lua @@ -0,0 +1,185 @@ +local Screen = require("tests.framework.screen") + +describe("conflict gutter preserves window options", function() + local screen, win + + before_each(function() + screen = Screen.new(80, 24) + win = screen:exec([[ + local lines = {} + for i = 1, 20 do lines[i] = 'content_' .. i end + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + vim.wo.signcolumn = 'yes' + vim.wo.foldcolumn = '0' + vim.wo.wrap = false + vim.wo.cursorline = false + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + return vim.api.nvim_get_current_win() + ]]) + end) + + after_each(function() + if screen then + screen:close() + screen = nil + end + end) + + local function attach() + screen:exec("require('codediff.ui.conflict').attach_gutter(...)", { win }) + end + + local function detach() + screen:exec("require('codediff.ui.conflict').detach_gutter(...)", { win }) + end + + local function set_column(value) + screen:exec("vim.wo.statuscolumn = ...", { value }) + end + + local function column() + return screen:exec("return vim.wo.statuscolumn") + end + + local function snapshot() + screen:flush() + local rows = {} + for row = 1, 12 do + rows[row] = screen:text(row, 1, 35) + end + return rows + end + + for _, options in ipairs({ + { name = "no numbers", number = false, relativenumber = false }, + { name = "absolute numbers", number = true, relativenumber = false }, + { name = "relative numbers", number = false, relativenumber = true }, + { name = "hybrid numbers", number = true, relativenumber = true }, + { name = "wide hybrid numbers", number = true, relativenumber = true, numberwidth = 7 }, + { name = "fold column", number = true, relativenumber = true, foldcolumn = "2" }, + { name = "multiple sign slots", number = true, relativenumber = true, signcolumn = "yes:2" }, + { name = "signs in the number column", number = true, relativenumber = true, signcolumn = "number" }, + { name = "a sign on the hybrid cursor row", number = true, relativenumber = true, signcolumn = "number", cursor = 3 }, + { name = "hidden signs", number = true, relativenumber = true, signcolumn = "no" }, + }) do + it("matches native rendering with " .. options.name, function() + screen:exec( + [[ + local options = ... + for name, value in pairs(options) do + if name ~= 'name' and name ~= 'cursor' then vim.wo[name] = value end + end + if options.cursor then vim.api.nvim_win_set_cursor(0, { options.cursor, 0 }) end + local ns = vim.api.nvim_create_namespace('test-other-signs') + vim.api.nvim_buf_set_extmark(0, ns, 2, 0, { sign_text = '!!', sign_hl_group = 'DiagnosticWarn' }) + ]], + { options } + ) + local native = snapshot() + attach() + assert.same(native, snapshot(), "attaching must not change native columns") + detach() + assert.same(native, snapshot(), "detaching must restore native columns") + end) + end + + it("preserves folded and wrapped rows", function() + screen:exec([[ + vim.wo.number = true + vim.wo.relativenumber = true + vim.wo.foldcolumn = '1' + vim.wo.foldmethod = 'manual' + vim.wo.wrap = true + vim.api.nvim_buf_set_lines(0, 0, 1, false, { string.rep('wrapped ', 15) }) + vim.cmd('3,5fold') + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + ]]) + local native = snapshot() + attach() + assert.same(native, snapshot()) + end) + + it("follows number option changes after attaching", function() + attach() + screen:exec("vim.wo.number = true; vim.wo.relativenumber = true") + local attached = snapshot() + detach() + assert.same(snapshot(), attached) + end) + + it("uses each drawing window's number options and cursor", function() + local other = screen:exec([[ + vim.wo.number = true + vim.wo.relativenumber = false + vim.cmd('rightbelow vsplit') + vim.wo.number = false + vim.wo.relativenumber = true + vim.api.nvim_win_set_cursor(0, { 10, 0 }) + return vim.api.nvim_get_current_win() + ]]) + for _, focus in ipairs({ win, other }) do + screen:exec( + [[ + local first, second, focus = ... + local conflict = require('codediff.ui.conflict') + conflict.detach_gutter(first) + conflict.detach_gutter(second) + vim.api.nvim_set_current_win(focus) + ]], + { win, other, focus } + ) + screen:flush() + local native = {} + for row = 1, 12 do + native[row] = screen:text(row, 1, 80) + end + screen:exec("require('codediff.ui.conflict').attach_gutter(...)", { win, other }) + screen:flush() + screen:expect_rows(1, 1, native, "different per-window number options") + end + end) + + it("leaves an existing custom statuscolumn untouched", function() + set_column("USER %C%s%l") + local native = snapshot() + attach() + assert.equals("USER %C%s%l", column()) + assert.same(native, snapshot()) + detach() + assert.equals("USER %C%s%l", column()) + end) + + it("does not restore a configuration it declined to own", function() + set_column("USER_A %l") + attach() + set_column("USER_B %l") + detach() + assert.equals("USER_B %l", column()) + end) + + it("does not overwrite a replacement installed while attached", function() + attach() + set_column("NEW_OWNER %l") + detach() + assert.equals("NEW_OWNER %l", column()) + end) + + it("does not reclaim a user's replacement on a repeated attach", function() + attach() + set_column("NEW_OWNER %l") + attach() + assert.equals("NEW_OWNER %l", column()) + detach() + assert.equals("NEW_OWNER %l", column()) + end) + + it("attaches and detaches repeatedly without losing the original option", function() + for _ = 1, 3 do + attach() + attach() + detach() + detach() + assert.equals("", column()) + end + end) +end)