diff --git a/lua/codediff/commands/handlers/merge.lua b/lua/codediff/commands/handlers/merge.lua index 38bc434f..dbc44877 100644 --- a/lua/codediff/commands/handlers/merge.lua +++ b/lua/codediff/commands/handlers/merge.lua @@ -51,7 +51,7 @@ function M.run(opts, global_opts) -- conflict_ours_position controls where :2 (OURS) appears on screen local ours_position = config.options.diff.conflict_ours_position or "right" - -- After conflict_window.lua's win_splitmove(rightbelow=false): + -- After the conflict view's win_splitmove(rightbelow=false): -- - original_win is on LEFT -- - modified_win is on RIGHT local original_rev, modified_rev diff --git a/lua/codediff/ui/conflict/actions.lua b/lua/codediff/ui/conflict/actions.lua deleted file mode 100644 index e7bc9369..00000000 --- a/lua/codediff/ui/conflict/actions.lua +++ /dev/null @@ -1,721 +0,0 @@ --- Conflict resolution actions (accept incoming/current/both, discard) -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") - ---- Apply text to result buffer at the conflict's range ---- @param result_bufnr number Result buffer ---- @param block table Conflict block with base_range and optional extmark_id ---- @param lines table Lines to insert ---- @param base_lines table Result-buffer seed content (auto-merged result), ---- used only for the content-search fallback when the ---- extmark is invalid. Indexed by result_range, falling ---- back to base_range for legacy paths. -local function apply_to_result(result_bufnr, block, lines, base_lines) - local start_row, end_row - - -- Method 1: Try using extmarks (robust against edits) - if block.extmark_id then - local mark = vim.api.nvim_buf_get_extmark_by_id(result_bufnr, tracking.tracking_ns, block.extmark_id, { details = true }) - if mark and #mark >= 3 then - start_row = mark[1] - end_row = mark[3].end_row - end - end - - -- Method 2: Fallback to content search or original range - if not start_row then - -- The result buffer seed (base_lines here) is the auto-merged Result, so - -- the slice for this conflict lives at result_range (which equals the - -- original BASE slice for unresolved conflict regions). Fall back to - -- base_range for legacy callers that never set result_range. - local range = block.result_range or block.base_range - -- For simplicity, we'll re-apply based on content matching - -- Find the seed slice in the result buffer - local base_content = {} - for i = range.start_line, range.end_line - 1 do - table.insert(base_content, base_lines[i] or "") - end - - local result_lines = vim.api.nvim_buf_get_lines(result_bufnr, 0, -1, false) - - -- Search for the seed content in result buffer - local found_start = nil - for i = 1, #result_lines - #base_content + 1 do - local match = true - for j = 1, #base_content do - if result_lines[i + j - 1] ~= base_content[j] then - match = false - break - end - end - if match then - found_start = i - break - end - end - - if found_start then - start_row = found_start - 1 - end_row = found_start - 1 + #base_content - else - -- Fallback: try to find by approximate position - start_row = math.min(range.start_line - 1, #result_lines) - end_row = math.min(range.end_line - 1, #result_lines) - end - end - - if start_row and end_row then - vim.api.nvim_buf_set_lines(result_bufnr, start_row, end_row, false, lines) - end -end - ---- Accept incoming (left/input1) side for the conflict under cursor ---- @param tabpage number ---- @return boolean success -function M.accept_incoming(tabpage) - local session = lifecycle.get_session(tabpage) - if not session then - vim.notify("[codediff] No active session", vim.log.levels.WARN) - return false - end - - if not session.conflict_blocks or #session.conflict_blocks == 0 then - vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) - return false - end - - -- Determine which buffer cursor is in and find the conflict - local current_buf = vim.api.nvim_get_current_buf() - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - local side = nil - - if current_buf == session.original_bufnr then - side = "left" - elseif current_buf == session.modified_bufnr then - side = "right" - else - vim.notify("[codediff] Cursor not in diff buffer", vim.log.levels.WARN) - return false - end - - local block = tracking.find_conflict_at_cursor(session, cursor_line, side, false) - if not block then - vim.notify("[codediff] No active conflict at cursor position", vim.log.levels.INFO) - return false - end - - -- Get incoming (left) content - local incoming_lines = tracking.get_lines_for_range(session.original_bufnr, block.output1_range.start_line, block.output1_range.end_line) - - -- Apply to result - local result_bufnr = session.result_bufnr - local base_lines = session.result_base_lines - if not result_bufnr or not base_lines then - vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) - return false - end - - apply_to_result(result_bufnr, block, incoming_lines, base_lines) - signs.refresh_all_conflict_signs(session) - auto_refresh.refresh_result_now(result_bufnr) - return true -end - ---- Accept current (right/input2) side for the conflict under cursor ---- @param tabpage number ---- @return boolean success -function M.accept_current(tabpage) - local session = lifecycle.get_session(tabpage) - if not session then - vim.notify("[codediff] No active session", vim.log.levels.WARN) - return false - end - - if not session.conflict_blocks or #session.conflict_blocks == 0 then - vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) - return false - end - - local current_buf = vim.api.nvim_get_current_buf() - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - local side = nil - - if current_buf == session.original_bufnr then - side = "left" - elseif current_buf == session.modified_bufnr then - side = "right" - else - vim.notify("[codediff] Cursor not in diff buffer", vim.log.levels.WARN) - return false - end - - local block = tracking.find_conflict_at_cursor(session, cursor_line, side, false) - if not block then - vim.notify("[codediff] No active conflict at cursor position", vim.log.levels.INFO) - return false - end - - -- Get current (right) content - local current_lines = tracking.get_lines_for_range(session.modified_bufnr, block.output2_range.start_line, block.output2_range.end_line) - - local result_bufnr = session.result_bufnr - local base_lines = session.result_base_lines - if not result_bufnr or not base_lines then - vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) - return false - end - - apply_to_result(result_bufnr, block, current_lines, base_lines) - signs.refresh_all_conflict_signs(session) - auto_refresh.refresh_result_now(result_bufnr) - return true -end - ---- Try to smart combine inputs like VSCode does ---- This interleaves character-level edits sorted by their position in base ---- Returns nil if edits overlap and cannot be combined ---- @param session table Session with buffer references ---- @param block table Conflict block with inner1, inner2 ---- @param first_input number 1 or 2 - which input takes priority on ties ---- @return table|nil Combined lines, or nil if cannot be combined -local function smart_combine_inputs(session, block, first_input) - local inner1 = block.inner1 or {} - local inner2 = block.inner2 or {} - - -- If either side has no inner changes, we can't do smart combination - -- (means entire block was replaced, not fine-grained edits) - if #inner1 == 0 or #inner2 == 0 then - return nil - end - - -- Collect all range edits with their source - -- Each inner has: original (position in base), modified (position in input) - local combined_edits = {} - - for _, inner in ipairs(inner1) do - table.insert(combined_edits, { - input_range = inner.original, -- Range in base file - output_range = inner.modified, -- Range in input file - input = 1, - }) - end - for _, inner in ipairs(inner2) do - table.insert(combined_edits, { - input_range = inner.original, - output_range = inner.modified, - input = 2, - }) - end - - -- Sort by position in base (input_range), with first_input taking priority on ties - -- This matches VSCode's: compareBy((d) => d.diff.inputRange, Range.compareRangesUsingStarts) - table.sort(combined_edits, function(a, b) - local a_start_line = a.input_range.start_line - local a_start_col = a.input_range.start_col - local b_start_line = b.input_range.start_line - local b_start_col = b.input_range.start_col - - if a_start_line ~= b_start_line then - return a_start_line < b_start_line - end - if a_start_col ~= b_start_col then - return a_start_col < b_start_col - end - -- Tie-breaker: first_input comes first (lower number = earlier) - local a_priority = (a.input == first_input) and 1 or 2 - local b_priority = (b.input == first_input) and 1 or 2 - return a_priority < b_priority - end) - - -- Get input buffer contents (VSCode uses textModel.getValueInRange on full models). - -- For the merge base we use session.merge_base_lines (true stage-:1 content) - -- rather than reading from result_bufnr — the Result buffer is now seeded - -- with the auto-merged content, so its line numbers no longer match - -- base_range. - local base_lines = session.merge_base_lines or session.result_base_lines or {} - local input1_bufnr = session.original_bufnr - local input2_bufnr = session.modified_bufnr - - -- Helper: get text from buffer between two positions (like VSCode's getValueInRange) - -- Positions are 1-based (line, col) - local function get_value_in_range(bufnr, start_line, start_col, end_line, end_col) - if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then - return "" - end - - local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) - if #lines == 0 then - return "" - end - - if #lines == 1 then - -- Single line: extract from start_col to end_col-1 - local line = lines[1] or "" - return line:sub(start_col, end_col - 1) - else - -- Multi-line: first line from start_col, middle lines full, last line to end_col-1 - local result = {} - result[1] = (lines[1] or ""):sub(start_col) - for i = 2, #lines - 1 do - result[#result + 1] = lines[i] or "" - end - result[#result + 1] = (lines[#lines] or ""):sub(1, end_col - 1) - return table.concat(result, "\n") - end - end - - -- Helper: get text from a string array (base_lines) between two 1-based - -- positions. Mirrors get_value_in_range but for in-memory line tables. - local function get_value_in_lines(source, start_line, start_col, end_line, end_col) - if start_line > end_line then - return "" - end - if start_line == end_line then - return (source[start_line] or ""):sub(start_col, end_col - 1) - end - local out = {} - out[1] = (source[start_line] or ""):sub(start_col) - for i = start_line + 1, end_line - 1 do - out[#out + 1] = source[i] or "" - end - out[#out + 1] = (source[end_line] or ""):sub(1, end_col - 1) - return table.concat(out, "\n") - end - - -- Build result text by walking through base and applying edits - -- This matches VSCode's editsToLineRangeEdit function - local base_range = block.base_range - local result_text = "" - - -- Start position: VSCode starts at end of line before base_range if exists - local starts_line_before = base_range.start_line > 1 - local current_line, current_col - if starts_line_before then - current_line = base_range.start_line - 1 - current_col = #(base_lines[current_line] or "") + 1 -- Position after last char (like getLineMaxColumn) - else - current_line = base_range.start_line - current_col = 1 - end - - for _, edit in ipairs(combined_edits) do - local diff_start_line = edit.input_range.start_line - local diff_start_col = edit.input_range.start_col - - -- Check overlap: current position must be <= edit start - if current_line > diff_start_line or (current_line == diff_start_line and current_col > diff_start_col) then - return nil -- Overlap detected, cannot combine - end - - -- Get base text from current position to edit start (read from base_lines, - -- not from result_bufnr, since the Result buffer no longer mirrors BASE) - local original_text = get_value_in_lines(base_lines, current_line, current_col, diff_start_line, diff_start_col) - - -- Handle virtual newline if edit starts past end of file - if diff_start_line > #base_lines then - original_text = original_text .. "\n" - end - - result_text = result_text .. original_text - - -- Get replacement text from input - local source_bufnr = (edit.input == 1) and input1_bufnr or input2_bufnr - local new_text = get_value_in_range(source_bufnr, edit.output_range.start_line, edit.output_range.start_col, edit.output_range.end_line, edit.output_range.end_col) - result_text = result_text .. new_text - - -- Move current position to end of edit's input_range - current_line = edit.input_range.end_line - current_col = edit.input_range.end_col - end - - -- Get remaining base text after last edit - local ends_line_after = base_range.end_line <= #base_lines - local end_line, end_col - if ends_line_after then - end_line = base_range.end_line - end_col = 1 - else - end_line = math.max(1, base_range.end_line - 1) - end_col = #(base_lines[end_line] or "") + 1 - end - - local remaining_text = get_value_in_lines(base_lines, current_line, current_col, end_line, end_col) - result_text = result_text .. remaining_text - - -- Split result into lines (like VSCode's splitLines) - local result_lines = {} - for line in (result_text .. "\n"):gmatch("([^\n]*)\n") do - table.insert(result_lines, line) - end - -- Remove the extra empty line from our gmatch pattern - if #result_lines > 0 and result_lines[#result_lines] == "" and not result_text:match("\n$") then - table.remove(result_lines) - end - - -- Trim leading line if we started before base_range - if starts_line_before and #result_lines > 0 then - if result_lines[1] ~= "" then - return nil -- First line should be empty - end - table.remove(result_lines, 1) - end - - -- Trim trailing line if we end after base_range - if ends_line_after and #result_lines > 0 then - if result_lines[#result_lines] ~= "" then - return nil -- Last line should be empty - end - table.remove(result_lines) - end - - return result_lines -end - ---- Dumb combine: just concatenate input1 then input2 (fallback) ---- @param input1_lines table ---- @param input2_lines table ---- @param first_input number 1 or 2 ---- @return table Combined lines -local function dumb_combine_inputs(input1_lines, input2_lines, first_input) - local combined = {} - local first_lines = (first_input == 1) and input1_lines or input2_lines - local second_lines = (first_input == 1) and input2_lines or input1_lines - - for _, line in ipairs(first_lines) do - table.insert(combined, line) - end - for _, line in ipairs(second_lines) do - table.insert(combined, line) - end - - return combined -end - ---- Accept both sides (smart combination like VSCode) for the conflict under cursor ---- @param tabpage number ---- @return boolean success -function M.accept_both(tabpage) - local session = lifecycle.get_session(tabpage) - if not session then - vim.notify("[codediff] No active session", vim.log.levels.WARN) - return false - end - - if not session.conflict_blocks or #session.conflict_blocks == 0 then - vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) - return false - end - - local current_buf = vim.api.nvim_get_current_buf() - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - local side = nil - - if current_buf == session.original_bufnr then - side = "left" - elseif current_buf == session.modified_bufnr then - side = "right" - else - vim.notify("[codediff] Cursor not in diff buffer", vim.log.levels.WARN) - return false - end - - local block = tracking.find_conflict_at_cursor(session, cursor_line, side, false) - if not block then - vim.notify("[codediff] No active conflict at cursor position", vim.log.levels.INFO) - return false - end - - local result_bufnr = session.result_bufnr - local base_lines = session.result_base_lines - if not result_bufnr or not base_lines then - vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) - return false - end - - -- Determine first_input based on which side the cursor is on (matches VSCode behavior) - -- If cursor is on left (incoming), incoming comes first - -- If cursor is on right (current), current comes first - local first_input = (side == "left") and 1 or 2 - - -- Try smart combination first (like VSCode's "Accept Combination") - local combined = smart_combine_inputs(session, block, first_input) - - if not combined then - -- Fallback to dumb combination (concatenate) - local incoming_lines = tracking.get_lines_for_range(session.original_bufnr, block.output1_range.start_line, block.output1_range.end_line) - local current_lines = tracking.get_lines_for_range(session.modified_bufnr, block.output2_range.start_line, block.output2_range.end_line) - combined = dumb_combine_inputs(incoming_lines, current_lines, first_input) - end - - apply_to_result(result_bufnr, block, combined, base_lines) - signs.refresh_all_conflict_signs(session) - auto_refresh.refresh_result_now(result_bufnr) - return true -end - ---- Discard both sides (reset to base) for the conflict under cursor ---- @param tabpage number ---- @return boolean success -function M.discard(tabpage) - local session = lifecycle.get_session(tabpage) - if not session then - vim.notify("[codediff] No active session", vim.log.levels.WARN) - return false - end - - if not session.conflict_blocks or #session.conflict_blocks == 0 then - vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) - return false - end - - local current_buf = vim.api.nvim_get_current_buf() - local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - local side = nil - - if current_buf == session.original_bufnr then - side = "left" - elseif current_buf == session.modified_bufnr then - side = "right" - else - vim.notify("[codediff] Cursor not in diff buffer", vim.log.levels.WARN) - return false - end - - local block = tracking.find_conflict_at_cursor(session, cursor_line, side, true) -- Allow resolved - if not block then - vim.notify("[codediff] No conflict at cursor position", vim.log.levels.INFO) - return false - end - - -- Get base content for this range. session.merge_base_lines holds the true - -- merge base (stage :1) so we can index it by base_range coordinates - -- regardless of what's been auto-merged into the Result seed. - local base_lines = session.merge_base_lines or session.result_base_lines - if not base_lines then - vim.notify("[codediff] No base lines available", vim.log.levels.ERROR) - return false - end - - local base_content = {} - for i = block.base_range.start_line, block.base_range.end_line - 1 do - table.insert(base_content, base_lines[i] or "") - end - - local result_bufnr = session.result_bufnr - if not result_bufnr then - vim.notify("[codediff] No result buffer", vim.log.levels.ERROR) - return false - end - - -- 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) - auto_refresh.refresh_result_now(result_bufnr) - return true -end - ---- Accept ALL incoming (left/input1) for all active conflicts ---- @param tabpage number ---- @return boolean success -function M.accept_all_incoming(tabpage) - local session = lifecycle.get_session(tabpage) - if not session then - vim.notify("[codediff] No active session", vim.log.levels.WARN) - return false - end - - if not session.conflict_blocks or #session.conflict_blocks == 0 then - vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) - return false - end - - local result_bufnr = session.result_bufnr - local base_lines = session.result_base_lines - if not result_bufnr or not base_lines then - vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) - return false - end - - local count = 0 - - -- Process blocks in REVERSE order (bottom-to-top) to avoid line offset issues - -- Wrap in undojoin for atomic undo - vim.api.nvim_buf_call(result_bufnr, function() - for i = #session.conflict_blocks, 1, -1 do - local block = session.conflict_blocks[i] - if tracking.is_block_active(session, block) then - if count > 0 then - pcall(vim.cmd, "undojoin") - end - local incoming_lines = tracking.get_lines_for_range(session.original_bufnr, block.output1_range.start_line, block.output1_range.end_line) - apply_to_result(result_bufnr, block, incoming_lines, base_lines) - count = count + 1 - end - end - end) - - signs.refresh_all_conflict_signs(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 -end - ---- Accept ALL current (right/input2) for all active conflicts ---- @param tabpage number ---- @return boolean success -function M.accept_all_current(tabpage) - local session = lifecycle.get_session(tabpage) - if not session then - vim.notify("[codediff] No active session", vim.log.levels.WARN) - return false - end - - if not session.conflict_blocks or #session.conflict_blocks == 0 then - vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) - return false - end - - local result_bufnr = session.result_bufnr - local base_lines = session.result_base_lines - if not result_bufnr or not base_lines then - vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) - return false - end - - local count = 0 - - vim.api.nvim_buf_call(result_bufnr, function() - for i = #session.conflict_blocks, 1, -1 do - local block = session.conflict_blocks[i] - if tracking.is_block_active(session, block) then - if count > 0 then - pcall(vim.cmd, "undojoin") - end - local current_lines = tracking.get_lines_for_range(session.modified_bufnr, block.output2_range.start_line, block.output2_range.end_line) - apply_to_result(result_bufnr, block, current_lines, base_lines) - count = count + 1 - end - end - end) - - signs.refresh_all_conflict_signs(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 -end - ---- Accept ALL both sides for all active conflicts ---- @param tabpage number ---- @param first_input number|nil Which input comes first (1=incoming, 2=current). Default: 1 ---- @return boolean success -function M.accept_all_both(tabpage, first_input) - first_input = first_input or 1 - - local session = lifecycle.get_session(tabpage) - if not session then - vim.notify("[codediff] No active session", vim.log.levels.WARN) - return false - end - - if not session.conflict_blocks or #session.conflict_blocks == 0 then - vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) - return false - end - - local result_bufnr = session.result_bufnr - local base_lines = session.result_base_lines - if not result_bufnr or not base_lines then - vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) - return false - end - - local count = 0 - - vim.api.nvim_buf_call(result_bufnr, function() - for i = #session.conflict_blocks, 1, -1 do - local block = session.conflict_blocks[i] - if tracking.is_block_active(session, block) then - if count > 0 then - pcall(vim.cmd, "undojoin") - end - - local incoming_lines = tracking.get_lines_for_range(session.original_bufnr, block.output1_range.start_line, block.output1_range.end_line) - local current_lines = tracking.get_lines_for_range(session.modified_bufnr, block.output2_range.start_line, block.output2_range.end_line) - - -- Combine both sides - local combined - if first_input == 1 then - combined = vim.list_extend(vim.list_extend({}, incoming_lines), current_lines) - else - combined = vim.list_extend(vim.list_extend({}, current_lines), incoming_lines) - end - - apply_to_result(result_bufnr, block, combined, base_lines) - count = count + 1 - end - end - end) - - signs.refresh_all_conflict_signs(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 -end - ---- Discard ALL changes (reset all conflicts to base) ---- @param tabpage number ---- @return boolean success -function M.discard_all(tabpage) - local session = lifecycle.get_session(tabpage) - if not session then - vim.notify("[codediff] No active session", vim.log.levels.WARN) - return false - end - - if not session.conflict_blocks or #session.conflict_blocks == 0 then - vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) - return false - end - - local result_bufnr = session.result_bufnr - local seed_lines = session.result_base_lines - -- Use the true merge base for the slice we write back; the seed only feeds - -- the content-search fallback in apply_to_result. - local base_lines = session.merge_base_lines or seed_lines - if not result_bufnr or not seed_lines or not base_lines then - vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) - return false - end - - local count = 0 - - vim.api.nvim_buf_call(result_bufnr, function() - for i = #session.conflict_blocks, 1, -1 do - local block = session.conflict_blocks[i] - -- For discard, we reset even resolved conflicts back to base - if count > 0 then - pcall(vim.cmd, "undojoin") - end - - local base_content = {} - for j = block.base_range.start_line, block.base_range.end_line - 1 do - table.insert(base_content, base_lines[j] or "") - end - - apply_to_result(result_bufnr, block, base_content, seed_lines) - count = count + 1 - end - end) - - signs.refresh_all_conflict_signs(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 -end - -return M diff --git a/lua/codediff/ui/conflict/init.lua b/lua/codediff/ui/conflict/init.lua index 921d6495..47e8e960 100644 --- a/lua/codediff/ui/conflict/init.lua +++ b/lua/codediff/ui/conflict/init.lua @@ -5,8 +5,7 @@ local M = {} -- Import submodules local tracking = require("codediff.ui.conflict.tracking") local signs = require("codediff.ui.conflict.signs") -local actions = require("codediff.ui.conflict.actions") -local diffget = require("codediff.ui.conflict.diffget") +local resolution = require("codediff.ui.conflict.resolution") local navigation = require("codediff.ui.conflict.navigation") local keymaps = require("codediff.ui.conflict.keymaps") @@ -18,19 +17,17 @@ M.initialize_tracking = tracking.initialize_tracking M.refresh_all_conflict_signs = signs.refresh_all_conflict_signs M.setup_sign_refresh_autocmd = signs.setup_sign_refresh_autocmd --- Delegate to actions module -M.accept_incoming = actions.accept_incoming -M.accept_current = actions.accept_current -M.accept_both = actions.accept_both -M.discard = actions.discard -M.accept_all_incoming = actions.accept_all_incoming -M.accept_all_current = actions.accept_all_current -M.accept_all_both = actions.accept_all_both -M.discard_all = actions.discard_all - --- Delegate to diffget module -M.diffget_incoming = diffget.diffget_incoming -M.diffget_current = diffget.diffget_current +-- Delegate to resolution module +M.accept_incoming = resolution.accept_incoming +M.accept_current = resolution.accept_current +M.accept_both = resolution.accept_both +M.discard = resolution.discard +M.accept_all_incoming = resolution.accept_all_incoming +M.accept_all_current = resolution.accept_all_current +M.accept_all_both = resolution.accept_all_both +M.discard_all = resolution.discard_all +M.diffget_incoming = resolution.diffget_incoming +M.diffget_current = resolution.diffget_current -- Delegate to navigation module M.navigate_next_conflict = navigation.navigate_next_conflict diff --git a/lua/codediff/ui/conflict/keymaps.lua b/lua/codediff/ui/conflict/keymaps.lua index ba0b4459..c328d4c5 100644 --- a/lua/codediff/ui/conflict/keymaps.lua +++ b/lua/codediff/ui/conflict/keymaps.lua @@ -5,31 +5,30 @@ local lifecycle = require("codediff.ui.lifecycle") local config = require("codediff.config") local resolve = require("codediff.keymap.resolve") local tracking = require("codediff.ui.conflict.tracking") -local actions = require("codediff.ui.conflict.actions") -local diffget = require("codediff.ui.conflict.diffget") +local resolution = require("codediff.ui.conflict.resolution") local navigation = require("codediff.ui.conflict.navigation") -- Dot-repeatable actions are expr mappings (see conflict.tracking). local REPEATABLE_ACTIONS = { - { key = "accept_incoming", fn = actions.accept_incoming, desc = "Accept incoming change" }, - { key = "accept_current", fn = actions.accept_current, desc = "Accept current change" }, - { key = "accept_both", fn = actions.accept_both, desc = "Accept both changes" }, - { key = "discard", fn = actions.discard, desc = "Discard changes (keep base)" }, + { key = "accept_incoming", fn = resolution.accept_incoming, desc = "Accept incoming change" }, + { key = "accept_current", fn = resolution.accept_current, desc = "Accept current change" }, + { key = "accept_both", fn = resolution.accept_both, desc = "Accept both changes" }, + { key = "discard", fn = resolution.discard, desc = "Discard changes (keep base)" }, } local PLAIN_ACTIONS = { - { key = "accept_all_incoming", fn = actions.accept_all_incoming, desc = "Accept ALL incoming changes" }, - { key = "accept_all_current", fn = actions.accept_all_current, desc = "Accept ALL current changes" }, - { key = "accept_all_both", fn = actions.accept_all_both, desc = "Accept ALL both changes" }, - { key = "discard_all", fn = actions.discard_all, desc = "Discard ALL, reset to base" }, + { key = "accept_all_incoming", fn = resolution.accept_all_incoming, desc = "Accept ALL incoming changes" }, + { key = "accept_all_current", fn = resolution.accept_all_current, desc = "Accept ALL current changes" }, + { key = "accept_all_both", fn = resolution.accept_all_both, desc = "Accept ALL both changes" }, + { key = "discard_all", fn = resolution.discard_all, desc = "Discard ALL, reset to base" }, { key = "next_conflict", fn = navigation.navigate_next_conflict, desc = "Next conflict" }, { key = "prev_conflict", fn = navigation.navigate_prev_conflict, desc = "Previous conflict" }, } -- Vimdiff-style numbered diffget, only meaningful on the result buffer. local RESULT_ONLY_ACTIONS = { - { key = "diffget_incoming", fn = diffget.diffget_incoming, desc = "Get hunk from incoming (2do)" }, - { key = "diffget_current", fn = diffget.diffget_current, desc = "Get hunk from current (3do)" }, + { key = "diffget_incoming", fn = resolution.diffget_incoming, desc = "Get hunk from incoming (2do)" }, + { key = "diffget_current", fn = resolution.diffget_current, desc = "Get hunk from current (3do)" }, } --- Setup conflict keymaps for a session diff --git a/lua/codediff/ui/merge_alignment.lua b/lua/codediff/ui/conflict/merge/alignment.lua similarity index 59% rename from lua/codediff/ui/merge_alignment.lua rename to lua/codediff/ui/conflict/merge/alignment.lua index 78ef1cfb..3c13bfb7 100644 --- a/lua/codediff/ui/merge_alignment.lua +++ b/lua/codediff/ui/conflict/merge/alignment.lua @@ -153,7 +153,7 @@ local function split_up_common_equal_range_mappings(equal_ranges_1, equal_ranges end -- Get alignments - exact port of VSCode's getAlignments -local function get_alignments(base_start, base_end, input1_start, input1_end, input2_start, input2_end, input1_inner_diffs, input2_inner_diffs) +function M.get_alignments(base_start, base_end, input1_start, input1_end, input2_start, input2_end, input1_inner_diffs, input2_inner_diffs) -- Get equal range mappings for both inputs local equal_ranges_1 = to_equal_range_mappings(input1_inner_diffs, base_start, base_end, input1_start, input1_end) local equal_ranges_2 = to_equal_range_mappings(input2_inner_diffs, base_start, base_end, input2_start, input2_end) @@ -232,7 +232,7 @@ end -- Exact port of VSCode's MappingAlignment.compute -- Takes changes from base->input1 and base->input2, returns aligned groups -local function compute_mapping_alignments(changes1, changes2) +function M.compute_mapping_alignments(changes1, changes2) -- Combine and sort all changes by base start line local combined = {} for _, c in ipairs(changes1 or {}) do @@ -362,214 +362,4 @@ local function compute_mapping_alignments(changes1, changes2) return alignments end -function M.compute_merge_fillers(base_to_input1_diff, base_to_input2_diff, base_lines, input1_lines, input2_lines) - -- Use VSCode's MappingAlignment.compute approach - local mapping_alignments = compute_mapping_alignments(base_to_input1_diff.changes, base_to_input2_diff.changes) - - local all_left_fillers = {} - local all_right_fillers = {} - local left_total = 0 - local right_total = 0 - - for _, ma in ipairs(mapping_alignments) do - -- Get line alignments using VSCode's getAlignments - local alignments = get_alignments( - ma.base_range.start_line, - ma.base_range.end_line, - ma.output1_range.start_line, - ma.output1_range.end_line, - ma.output2_range.start_line, - ma.output2_range.end_line, - ma.inner1, - ma.inner2 - ) - - -- Convert alignments to fillers - for _, a in ipairs(alignments) do - if a.input1_line and a.input2_line then - local left_adj = a.input1_line + left_total - local right_adj = a.input2_line + right_total - local mx = math.max(left_adj, right_adj) - - if mx - left_adj > 0 then - table.insert(all_left_fillers, { after_line = a.input1_line - 1, count = mx - left_adj }) - left_total = left_total + (mx - left_adj) - end - if mx - right_adj > 0 then - table.insert(all_right_fillers, { after_line = a.input2_line - 1, count = mx - right_adj }) - right_total = right_total + (mx - right_adj) - end - end - end - end - - return all_left_fillers, all_right_fillers -end - --- Compute fillers AND identify which changes are in conflict regions --- A conflict region is where BOTH input1 and input2 have changes to the same base region --- This matches VSCode's behavior of only highlighting conflicting changes -function M.compute_merge_fillers_and_conflicts(base_to_input1_diff, base_to_input2_diff, base_lines, input1_lines, input2_lines) - -- Use VSCode's MappingAlignment.compute approach - local mapping_alignments = compute_mapping_alignments(base_to_input1_diff.changes, base_to_input2_diff.changes) - - local all_left_fillers = {} - local all_right_fillers = {} - local left_total = 0 - local right_total = 0 - - -- Track which changes are in conflict regions - local conflict_left_changes = {} - local conflict_right_changes = {} - - -- Track conflict blocks (for accept/reject actions) - local conflict_blocks = {} - - for _, ma in ipairs(mapping_alignments) do - -- A region is conflicting if BOTH sides have changes (inner1 and inner2 both non-empty) - -- OR if both sides have changes (checked by looking at if the ranges differ from base) - local has_left_changes = #ma.inner1 > 0 or (ma.output1_range.end_line - ma.output1_range.start_line) ~= (ma.base_range.end_line - ma.base_range.start_line) - local has_right_changes = #ma.inner2 > 0 or (ma.output2_range.end_line - ma.output2_range.start_line) ~= (ma.base_range.end_line - ma.base_range.start_line) - local is_conflict = has_left_changes and has_right_changes - - if is_conflict then - -- Add to conflict blocks for action handling - table.insert(conflict_blocks, ma) - - -- Collect the actual diff changes that fall within this alignment's base range - for _, change in ipairs(base_to_input1_diff.changes or {}) do - if change.original.start_line >= ma.base_range.start_line and change.original.end_line <= ma.base_range.end_line then - table.insert(conflict_left_changes, change) - end - end - for _, change in ipairs(base_to_input2_diff.changes or {}) do - if change.original.start_line >= ma.base_range.start_line and change.original.end_line <= ma.base_range.end_line then - table.insert(conflict_right_changes, change) - end - end - end - - -- Get line alignments using VSCode's getAlignments - local alignments = get_alignments( - ma.base_range.start_line, - ma.base_range.end_line, - ma.output1_range.start_line, - ma.output1_range.end_line, - ma.output2_range.start_line, - ma.output2_range.end_line, - ma.inner1, - ma.inner2 - ) - - -- Convert alignments to fillers - for _, a in ipairs(alignments) do - if a.input1_line and a.input2_line then - local left_adj = a.input1_line + left_total - local right_adj = a.input2_line + right_total - local mx = math.max(left_adj, right_adj) - - if mx - left_adj > 0 then - table.insert(all_left_fillers, { after_line = a.input1_line - 1, count = mx - left_adj }) - left_total = left_total + (mx - left_adj) - end - if mx - right_adj > 0 then - table.insert(all_right_fillers, { after_line = a.input2_line - 1, count = mx - right_adj }) - right_total = right_total + (mx - right_adj) - end - end - end - end - - return { - left_fillers = all_left_fillers, - right_fillers = all_right_fillers, - conflict_blocks = conflict_blocks, - }, conflict_left_changes, conflict_right_changes -end - ---- Compute the auto-merged Result buffer content. ---- Ports VSCode's MergeEditorModel.computeAutoMergedResult(). ---- For each base-range group: ---- * only input1 changed -> take input1 lines ---- * only input2 changed -> take input2 lines ---- * both changed identically -> take input1 lines ---- * both changed differently (conflict) -> keep base lines (user resolves) ---- Lines outside any group are copied straight from base. ---- ---- Also returns conflict_blocks with `result_range` (1-based, inclusive start / ---- exclusive end) marking where each unresolved conflict lives in the merged ---- buffer, so extmark tracking can anchor to merged-content line numbers ---- instead of pure-base line numbers. ---- ---- @param base_to_input1_diff table { changes = {...} } ---- @param base_to_input2_diff table { changes = {...} } ---- @param base_lines string[] ---- @param input1_lines string[] ---- @param input2_lines string[] ---- @return string[] merged_lines, table[] conflict_blocks -function M.compute_auto_merged_result(base_to_input1_diff, base_to_input2_diff, base_lines, input1_lines, input2_lines) - local mapping_alignments = compute_mapping_alignments(base_to_input1_diff.changes, base_to_input2_diff.changes) - - local result_lines = {} - local conflict_blocks = {} - - local function append_range(source, start_line, end_line_exclusive) - for i = start_line, end_line_exclusive - 1 do - table.insert(result_lines, source[i] or "") - end - end - - local function ranges_equal_content(s1, r1, s2, r2) - local len1 = r1.end_line - r1.start_line - local len2 = r2.end_line - r2.start_line - if len1 ~= len2 then - return false - end - for i = 0, len1 - 1 do - if (s1[r1.start_line + i] or "") ~= (s2[r2.start_line + i] or "") then - return false - end - end - return true - end - - local base_cursor = 1 -- 1-based line index into base_lines, inclusive - - for _, ma in ipairs(mapping_alignments) do - -- Copy unchanged base lines preceding this group. - append_range(base_lines, base_cursor, ma.base_range.start_line) - base_cursor = ma.base_range.end_line - - local has1 = ma.has_input1 - local has2 = ma.has_input2 - - if has1 and not has2 then - append_range(input1_lines, ma.output1_range.start_line, ma.output1_range.end_line) - elseif has2 and not has1 then - append_range(input2_lines, ma.output2_range.start_line, ma.output2_range.end_line) - elseif has1 and has2 and ranges_equal_content(input1_lines, ma.output1_range, input2_lines, ma.output2_range) then - -- Both sides made the identical change -> not a conflict, apply once. - append_range(input1_lines, ma.output1_range.start_line, ma.output1_range.end_line) - else - -- True conflict: keep base, leave for the user to resolve. - local conflict_start = #result_lines + 1 - append_range(base_lines, ma.base_range.start_line, ma.base_range.end_line) - local conflict_end_exclusive = #result_lines + 1 - table.insert(conflict_blocks, { - base_range = ma.base_range, - output1_range = ma.output1_range, - output2_range = ma.output2_range, - inner1 = ma.inner1, - inner2 = ma.inner2, - result_range = { start_line = conflict_start, end_line = conflict_end_exclusive }, - }) - end - end - - -- Copy any remaining base lines after the last group. - append_range(base_lines, base_cursor, #base_lines + 1) - - return result_lines, conflict_blocks -end - return M diff --git a/lua/codediff/ui/conflict/merge/auto_merge.lua b/lua/codediff/ui/conflict/merge/auto_merge.lua new file mode 100644 index 00000000..dfb0e4ab --- /dev/null +++ b/lua/codediff/ui/conflict/merge/auto_merge.lua @@ -0,0 +1,91 @@ +-- Computes the initial auto-merged Result content. +local M = {} + +local compute_mapping_alignments = require("codediff.ui.conflict.merge.alignment").compute_mapping_alignments + +--- Compute the auto-merged Result buffer content. +--- Ports VSCode's MergeEditorModel.computeAutoMergedResult(). +--- For each base-range group: +--- * only input1 changed -> take input1 lines +--- * only input2 changed -> take input2 lines +--- * both changed identically -> take input1 lines +--- * both changed differently (conflict) -> keep base lines (user resolves) +--- Lines outside any group are copied straight from base. +--- +--- Also returns conflict_blocks with `result_range` (1-based, inclusive start / +--- exclusive end) marking where each unresolved conflict lives in the merged +--- buffer, so extmark tracking can anchor to merged-content line numbers +--- instead of pure-base line numbers. +--- +--- @param base_to_input1_diff table { changes = {...} } +--- @param base_to_input2_diff table { changes = {...} } +--- @param base_lines string[] +--- @param input1_lines string[] +--- @param input2_lines string[] +--- @return string[] merged_lines, table[] conflict_blocks +function M.compute_auto_merged_result(base_to_input1_diff, base_to_input2_diff, base_lines, input1_lines, input2_lines) + local mapping_alignments = compute_mapping_alignments(base_to_input1_diff.changes, base_to_input2_diff.changes) + + local result_lines = {} + local conflict_blocks = {} + + local function append_range(source, start_line, end_line_exclusive) + for i = start_line, end_line_exclusive - 1 do + table.insert(result_lines, source[i] or "") + end + end + + local function ranges_equal_content(s1, r1, s2, r2) + local len1 = r1.end_line - r1.start_line + local len2 = r2.end_line - r2.start_line + if len1 ~= len2 then + return false + end + for i = 0, len1 - 1 do + if (s1[r1.start_line + i] or "") ~= (s2[r2.start_line + i] or "") then + return false + end + end + return true + end + + local base_cursor = 1 -- 1-based line index into base_lines, inclusive + + for _, ma in ipairs(mapping_alignments) do + -- Copy unchanged base lines preceding this group. + append_range(base_lines, base_cursor, ma.base_range.start_line) + base_cursor = ma.base_range.end_line + + local has1 = ma.has_input1 + local has2 = ma.has_input2 + + if has1 and not has2 then + append_range(input1_lines, ma.output1_range.start_line, ma.output1_range.end_line) + elseif has2 and not has1 then + append_range(input2_lines, ma.output2_range.start_line, ma.output2_range.end_line) + elseif has1 and has2 and ranges_equal_content(input1_lines, ma.output1_range, input2_lines, ma.output2_range) then + -- Both sides made the identical change -> not a conflict, apply once. + append_range(input1_lines, ma.output1_range.start_line, ma.output1_range.end_line) + else + -- True conflict: keep base, leave for the user to resolve. + local conflict_start = #result_lines + 1 + append_range(base_lines, ma.base_range.start_line, ma.base_range.end_line) + local conflict_end_exclusive = #result_lines + 1 + table.insert(conflict_blocks, { + base_range = ma.base_range, + output1_range = ma.output1_range, + output2_range = ma.output2_range, + inner1 = ma.inner1, + inner2 = ma.inner2, + result_range = { start_line = conflict_start, end_line = conflict_end_exclusive }, + }) + end + end + + -- Copy any remaining base lines after the last group. + append_range(base_lines, base_cursor, #base_lines + 1) + + return result_lines, conflict_blocks +end + +return M diff --git a/lua/codediff/ui/conflict/merge/fillers.lua b/lua/codediff/ui/conflict/merge/fillers.lua new file mode 100644 index 00000000..fae600c3 --- /dev/null +++ b/lua/codediff/ui/conflict/merge/fillers.lua @@ -0,0 +1,133 @@ +-- Computes input-pane fillers and conflict regions. +local M = {} + +local alignment = require("codediff.ui.conflict.merge.alignment") +local compute_mapping_alignments = alignment.compute_mapping_alignments +local get_alignments = alignment.get_alignments + +function M.compute_merge_fillers(base_to_input1_diff, base_to_input2_diff, base_lines, input1_lines, input2_lines) + -- Use VSCode's MappingAlignment.compute approach + local mapping_alignments = compute_mapping_alignments(base_to_input1_diff.changes, base_to_input2_diff.changes) + + local all_left_fillers = {} + local all_right_fillers = {} + local left_total = 0 + local right_total = 0 + + for _, ma in ipairs(mapping_alignments) do + -- Get line alignments using VSCode's getAlignments + local alignments = get_alignments( + ma.base_range.start_line, + ma.base_range.end_line, + ma.output1_range.start_line, + ma.output1_range.end_line, + ma.output2_range.start_line, + ma.output2_range.end_line, + ma.inner1, + ma.inner2 + ) + + -- Convert alignments to fillers + for _, a in ipairs(alignments) do + if a.input1_line and a.input2_line then + local left_adj = a.input1_line + left_total + local right_adj = a.input2_line + right_total + local mx = math.max(left_adj, right_adj) + + if mx - left_adj > 0 then + table.insert(all_left_fillers, { after_line = a.input1_line - 1, count = mx - left_adj }) + left_total = left_total + (mx - left_adj) + end + if mx - right_adj > 0 then + table.insert(all_right_fillers, { after_line = a.input2_line - 1, count = mx - right_adj }) + right_total = right_total + (mx - right_adj) + end + end + end + end + + return all_left_fillers, all_right_fillers +end + +-- Compute fillers AND identify which changes are in conflict regions +-- A conflict region is where BOTH input1 and input2 have changes to the same base region +-- This matches VSCode's behavior of only highlighting conflicting changes +function M.compute_merge_fillers_and_conflicts(base_to_input1_diff, base_to_input2_diff, base_lines, input1_lines, input2_lines) + -- Use VSCode's MappingAlignment.compute approach + local mapping_alignments = compute_mapping_alignments(base_to_input1_diff.changes, base_to_input2_diff.changes) + + local all_left_fillers = {} + local all_right_fillers = {} + local left_total = 0 + local right_total = 0 + + -- Track which changes are in conflict regions + local conflict_left_changes = {} + local conflict_right_changes = {} + + -- Track conflict blocks (for accept/reject actions) + local conflict_blocks = {} + + for _, ma in ipairs(mapping_alignments) do + -- A region is conflicting if BOTH sides have changes (inner1 and inner2 both non-empty) + -- OR if both sides have changes (checked by looking at if the ranges differ from base) + local has_left_changes = #ma.inner1 > 0 or (ma.output1_range.end_line - ma.output1_range.start_line) ~= (ma.base_range.end_line - ma.base_range.start_line) + local has_right_changes = #ma.inner2 > 0 or (ma.output2_range.end_line - ma.output2_range.start_line) ~= (ma.base_range.end_line - ma.base_range.start_line) + local is_conflict = has_left_changes and has_right_changes + + if is_conflict then + -- Add to conflict blocks for action handling + table.insert(conflict_blocks, ma) + + -- Collect the actual diff changes that fall within this alignment's base range + for _, change in ipairs(base_to_input1_diff.changes or {}) do + if change.original.start_line >= ma.base_range.start_line and change.original.end_line <= ma.base_range.end_line then + table.insert(conflict_left_changes, change) + end + end + for _, change in ipairs(base_to_input2_diff.changes or {}) do + if change.original.start_line >= ma.base_range.start_line and change.original.end_line <= ma.base_range.end_line then + table.insert(conflict_right_changes, change) + end + end + end + + -- Get line alignments using VSCode's getAlignments + local alignments = get_alignments( + ma.base_range.start_line, + ma.base_range.end_line, + ma.output1_range.start_line, + ma.output1_range.end_line, + ma.output2_range.start_line, + ma.output2_range.end_line, + ma.inner1, + ma.inner2 + ) + + -- Convert alignments to fillers + for _, a in ipairs(alignments) do + if a.input1_line and a.input2_line then + local left_adj = a.input1_line + left_total + local right_adj = a.input2_line + right_total + local mx = math.max(left_adj, right_adj) + + if mx - left_adj > 0 then + table.insert(all_left_fillers, { after_line = a.input1_line - 1, count = mx - left_adj }) + left_total = left_total + (mx - left_adj) + end + if mx - right_adj > 0 then + table.insert(all_right_fillers, { after_line = a.input2_line - 1, count = mx - right_adj }) + right_total = right_total + (mx - right_adj) + end + end + end + end + + return { + left_fillers = all_left_fillers, + right_fillers = all_right_fillers, + conflict_blocks = conflict_blocks, + }, conflict_left_changes, conflict_right_changes +end + +return M diff --git a/lua/codediff/ui/conflict/merge/init.lua b/lua/codediff/ui/conflict/merge/init.lua new file mode 100644 index 00000000..d839f6e7 --- /dev/null +++ b/lua/codediff/ui/conflict/merge/init.lua @@ -0,0 +1,11 @@ +-- Three-way merge calculations for conflict views. +local M = {} + +local fillers = require("codediff.ui.conflict.merge.fillers") +local auto_merge = require("codediff.ui.conflict.merge.auto_merge") + +M.compute_merge_fillers = fillers.compute_merge_fillers +M.compute_merge_fillers_and_conflicts = fillers.compute_merge_fillers_and_conflicts +M.compute_auto_merged_result = auto_merge.compute_auto_merged_result + +return M diff --git a/lua/codediff/ui/conflict/resolution/block.lua b/lua/codediff/ui/conflict/resolution/block.lua new file mode 100644 index 00000000..ae2e56a7 --- /dev/null +++ b/lua/codediff/ui/conflict/resolution/block.lua @@ -0,0 +1,239 @@ +-- Resolution commands for the conflict block under the cursor. +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 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 +local dumb_combine_inputs = combine.dumb_combine_inputs + +--- Accept incoming (left/input1) side for the conflict under cursor +--- @param tabpage number +--- @return boolean success +function M.accept_incoming(tabpage) + local session = lifecycle.get_session(tabpage) + if not session then + vim.notify("[codediff] No active session", vim.log.levels.WARN) + return false + end + + if not session.conflict_blocks or #session.conflict_blocks == 0 then + vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) + return false + end + + -- Determine which buffer cursor is in and find the conflict + local current_buf = vim.api.nvim_get_current_buf() + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] + local side = nil + + if current_buf == session.original_bufnr then + side = "left" + elseif current_buf == session.modified_bufnr then + side = "right" + else + vim.notify("[codediff] Cursor not in diff buffer", vim.log.levels.WARN) + return false + end + + local block = tracking.find_conflict_at_cursor(session, cursor_line, side, false) + if not block then + vim.notify("[codediff] No active conflict at cursor position", vim.log.levels.INFO) + return false + end + + -- Get incoming (left) content + local incoming_lines = tracking.get_lines_for_range(session.original_bufnr, block.output1_range.start_line, block.output1_range.end_line) + + -- Apply to result + local result_bufnr = session.result_bufnr + local base_lines = session.result_base_lines + if not result_bufnr or not base_lines then + vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) + return false + end + + apply_to_result(result_bufnr, block, incoming_lines, base_lines) + signs.refresh_all_conflict_signs(session) + auto_refresh.refresh_result_now(result_bufnr) + return true +end + +--- Accept current (right/input2) side for the conflict under cursor +--- @param tabpage number +--- @return boolean success +function M.accept_current(tabpage) + local session = lifecycle.get_session(tabpage) + if not session then + vim.notify("[codediff] No active session", vim.log.levels.WARN) + return false + end + + if not session.conflict_blocks or #session.conflict_blocks == 0 then + vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) + return false + end + + local current_buf = vim.api.nvim_get_current_buf() + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] + local side = nil + + if current_buf == session.original_bufnr then + side = "left" + elseif current_buf == session.modified_bufnr then + side = "right" + else + vim.notify("[codediff] Cursor not in diff buffer", vim.log.levels.WARN) + return false + end + + local block = tracking.find_conflict_at_cursor(session, cursor_line, side, false) + if not block then + vim.notify("[codediff] No active conflict at cursor position", vim.log.levels.INFO) + return false + end + + -- Get current (right) content + local current_lines = tracking.get_lines_for_range(session.modified_bufnr, block.output2_range.start_line, block.output2_range.end_line) + + local result_bufnr = session.result_bufnr + local base_lines = session.result_base_lines + if not result_bufnr or not base_lines then + vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) + return false + end + + apply_to_result(result_bufnr, block, current_lines, base_lines) + signs.refresh_all_conflict_signs(session) + auto_refresh.refresh_result_now(result_bufnr) + return true +end + +--- Accept both sides (smart combination like VSCode) for the conflict under cursor +--- @param tabpage number +--- @return boolean success +function M.accept_both(tabpage) + local session = lifecycle.get_session(tabpage) + if not session then + vim.notify("[codediff] No active session", vim.log.levels.WARN) + return false + end + + if not session.conflict_blocks or #session.conflict_blocks == 0 then + vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) + return false + end + + local current_buf = vim.api.nvim_get_current_buf() + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] + local side = nil + + if current_buf == session.original_bufnr then + side = "left" + elseif current_buf == session.modified_bufnr then + side = "right" + else + vim.notify("[codediff] Cursor not in diff buffer", vim.log.levels.WARN) + return false + end + + local block = tracking.find_conflict_at_cursor(session, cursor_line, side, false) + if not block then + vim.notify("[codediff] No active conflict at cursor position", vim.log.levels.INFO) + return false + end + + local result_bufnr = session.result_bufnr + local base_lines = session.result_base_lines + if not result_bufnr or not base_lines then + vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) + return false + end + + -- Determine first_input based on which side the cursor is on (matches VSCode behavior) + -- If cursor is on left (incoming), incoming comes first + -- If cursor is on right (current), current comes first + local first_input = (side == "left") and 1 or 2 + + -- Try smart combination first (like VSCode's "Accept Combination") + local combined = smart_combine_inputs(session, block, first_input) + + if not combined then + -- Fallback to dumb combination (concatenate) + local incoming_lines = tracking.get_lines_for_range(session.original_bufnr, block.output1_range.start_line, block.output1_range.end_line) + local current_lines = tracking.get_lines_for_range(session.modified_bufnr, block.output2_range.start_line, block.output2_range.end_line) + combined = dumb_combine_inputs(incoming_lines, current_lines, first_input) + end + + apply_to_result(result_bufnr, block, combined, base_lines) + signs.refresh_all_conflict_signs(session) + auto_refresh.refresh_result_now(result_bufnr) + return true +end + +--- Discard both sides (reset to base) for the conflict under cursor +--- @param tabpage number +--- @return boolean success +function M.discard(tabpage) + local session = lifecycle.get_session(tabpage) + if not session then + vim.notify("[codediff] No active session", vim.log.levels.WARN) + return false + end + + if not session.conflict_blocks or #session.conflict_blocks == 0 then + vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) + return false + end + + local current_buf = vim.api.nvim_get_current_buf() + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] + local side = nil + + if current_buf == session.original_bufnr then + side = "left" + elseif current_buf == session.modified_bufnr then + side = "right" + else + vim.notify("[codediff] Cursor not in diff buffer", vim.log.levels.WARN) + return false + end + + local block = tracking.find_conflict_at_cursor(session, cursor_line, side, true) -- Allow resolved + if not block then + vim.notify("[codediff] No conflict at cursor position", vim.log.levels.INFO) + return false + end + + -- Get base content for this range. session.merge_base_lines holds the true + -- merge base (stage :1) so we can index it by base_range coordinates + -- regardless of what's been auto-merged into the Result seed. + local base_lines = session.merge_base_lines or session.result_base_lines + if not base_lines then + vim.notify("[codediff] No base lines available", vim.log.levels.ERROR) + return false + end + + local base_content = {} + for i = block.base_range.start_line, block.base_range.end_line - 1 do + table.insert(base_content, base_lines[i] or "") + end + + local result_bufnr = session.result_bufnr + if not result_bufnr then + vim.notify("[codediff] No result buffer", vim.log.levels.ERROR) + return false + end + + -- 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) + auto_refresh.refresh_result_now(result_bufnr) + return true +end + +return M diff --git a/lua/codediff/ui/conflict/resolution/combine.lua b/lua/codediff/ui/conflict/resolution/combine.lua new file mode 100644 index 00000000..09465ba1 --- /dev/null +++ b/lua/codediff/ui/conflict/resolution/combine.lua @@ -0,0 +1,224 @@ +-- Combines both sides of one conflict block. +local M = {} + +--- Try to smart combine inputs like VSCode does +--- This interleaves character-level edits sorted by their position in base +--- Returns nil if edits overlap and cannot be combined +--- @param session table Session with buffer references +--- @param block table Conflict block with inner1, inner2 +--- @param first_input number 1 or 2 - which input takes priority on ties +--- @return table|nil Combined lines, or nil if cannot be combined +function M.smart_combine_inputs(session, block, first_input) + local inner1 = block.inner1 or {} + local inner2 = block.inner2 or {} + + -- If either side has no inner changes, we can't do smart combination + -- (means entire block was replaced, not fine-grained edits) + if #inner1 == 0 or #inner2 == 0 then + return nil + end + + -- Collect all range edits with their source + -- Each inner has: original (position in base), modified (position in input) + local combined_edits = {} + + for _, inner in ipairs(inner1) do + table.insert(combined_edits, { + input_range = inner.original, -- Range in base file + output_range = inner.modified, -- Range in input file + input = 1, + }) + end + for _, inner in ipairs(inner2) do + table.insert(combined_edits, { + input_range = inner.original, + output_range = inner.modified, + input = 2, + }) + end + + -- Sort by position in base (input_range), with first_input taking priority on ties + -- This matches VSCode's: compareBy((d) => d.diff.inputRange, Range.compareRangesUsingStarts) + table.sort(combined_edits, function(a, b) + local a_start_line = a.input_range.start_line + local a_start_col = a.input_range.start_col + local b_start_line = b.input_range.start_line + local b_start_col = b.input_range.start_col + + if a_start_line ~= b_start_line then + return a_start_line < b_start_line + end + if a_start_col ~= b_start_col then + return a_start_col < b_start_col + end + -- Tie-breaker: first_input comes first (lower number = earlier) + local a_priority = (a.input == first_input) and 1 or 2 + local b_priority = (b.input == first_input) and 1 or 2 + return a_priority < b_priority + end) + + -- Get input buffer contents (VSCode uses textModel.getValueInRange on full models). + -- For the merge base we use session.merge_base_lines (true stage-:1 content) + -- rather than reading from result_bufnr — the Result buffer is now seeded + -- with the auto-merged content, so its line numbers no longer match + -- base_range. + local base_lines = session.merge_base_lines or session.result_base_lines or {} + local input1_bufnr = session.original_bufnr + local input2_bufnr = session.modified_bufnr + + -- Helper: get text from buffer between two positions (like VSCode's getValueInRange) + -- Positions are 1-based (line, col) + local function get_value_in_range(bufnr, start_line, start_col, end_line, end_col) + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return "" + end + + local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) + if #lines == 0 then + return "" + end + + if #lines == 1 then + -- Single line: extract from start_col to end_col-1 + local line = lines[1] or "" + return line:sub(start_col, end_col - 1) + else + -- Multi-line: first line from start_col, middle lines full, last line to end_col-1 + local result = {} + result[1] = (lines[1] or ""):sub(start_col) + for i = 2, #lines - 1 do + result[#result + 1] = lines[i] or "" + end + result[#result + 1] = (lines[#lines] or ""):sub(1, end_col - 1) + return table.concat(result, "\n") + end + end + + -- Helper: get text from a string array (base_lines) between two 1-based + -- positions. Mirrors get_value_in_range but for in-memory line tables. + local function get_value_in_lines(source, start_line, start_col, end_line, end_col) + if start_line > end_line then + return "" + end + if start_line == end_line then + return (source[start_line] or ""):sub(start_col, end_col - 1) + end + local out = {} + out[1] = (source[start_line] or ""):sub(start_col) + for i = start_line + 1, end_line - 1 do + out[#out + 1] = source[i] or "" + end + out[#out + 1] = (source[end_line] or ""):sub(1, end_col - 1) + return table.concat(out, "\n") + end + + -- Build result text by walking through base and applying edits + -- This matches VSCode's editsToLineRangeEdit function + local base_range = block.base_range + local result_text = "" + + -- Start position: VSCode starts at end of line before base_range if exists + local starts_line_before = base_range.start_line > 1 + local current_line, current_col + if starts_line_before then + current_line = base_range.start_line - 1 + current_col = #(base_lines[current_line] or "") + 1 -- Position after last char (like getLineMaxColumn) + else + current_line = base_range.start_line + current_col = 1 + end + + for _, edit in ipairs(combined_edits) do + local diff_start_line = edit.input_range.start_line + local diff_start_col = edit.input_range.start_col + + -- Check overlap: current position must be <= edit start + if current_line > diff_start_line or (current_line == diff_start_line and current_col > diff_start_col) then + return nil -- Overlap detected, cannot combine + end + + -- Get base text from current position to edit start (read from base_lines, + -- not from result_bufnr, since the Result buffer no longer mirrors BASE) + local original_text = get_value_in_lines(base_lines, current_line, current_col, diff_start_line, diff_start_col) + + -- Handle virtual newline if edit starts past end of file + if diff_start_line > #base_lines then + original_text = original_text .. "\n" + end + + result_text = result_text .. original_text + + -- Get replacement text from input + local source_bufnr = (edit.input == 1) and input1_bufnr or input2_bufnr + local new_text = get_value_in_range(source_bufnr, edit.output_range.start_line, edit.output_range.start_col, edit.output_range.end_line, edit.output_range.end_col) + result_text = result_text .. new_text + + -- Move current position to end of edit's input_range + current_line = edit.input_range.end_line + current_col = edit.input_range.end_col + end + + -- Get remaining base text after last edit + local ends_line_after = base_range.end_line <= #base_lines + local end_line, end_col + if ends_line_after then + end_line = base_range.end_line + end_col = 1 + else + end_line = math.max(1, base_range.end_line - 1) + end_col = #(base_lines[end_line] or "") + 1 + end + + local remaining_text = get_value_in_lines(base_lines, current_line, current_col, end_line, end_col) + result_text = result_text .. remaining_text + + -- Split result into lines (like VSCode's splitLines) + local result_lines = {} + for line in (result_text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(result_lines, line) + end + -- Remove the extra empty line from our gmatch pattern + if #result_lines > 0 and result_lines[#result_lines] == "" and not result_text:match("\n$") then + table.remove(result_lines) + end + + -- Trim leading line if we started before base_range + if starts_line_before and #result_lines > 0 then + if result_lines[1] ~= "" then + return nil -- First line should be empty + end + table.remove(result_lines, 1) + end + + -- Trim trailing line if we end after base_range + if ends_line_after and #result_lines > 0 then + if result_lines[#result_lines] ~= "" then + return nil -- Last line should be empty + end + table.remove(result_lines) + end + + return result_lines +end + +--- Dumb combine: just concatenate input1 then input2 (fallback) +--- @param input1_lines table +--- @param input2_lines table +--- @param first_input number 1 or 2 +--- @return table Combined lines +function M.dumb_combine_inputs(input1_lines, input2_lines, first_input) + local combined = {} + local first_lines = (first_input == 1) and input1_lines or input2_lines + local second_lines = (first_input == 1) and input2_lines or input1_lines + + for _, line in ipairs(first_lines) do + table.insert(combined, line) + end + for _, line in ipairs(second_lines) do + table.insert(combined, line) + end + + return combined +end + +return M diff --git a/lua/codediff/ui/conflict/diffget.lua b/lua/codediff/ui/conflict/resolution/diffget.lua similarity index 100% rename from lua/codediff/ui/conflict/diffget.lua rename to lua/codediff/ui/conflict/resolution/diffget.lua diff --git a/lua/codediff/ui/conflict/resolution/file.lua b/lua/codediff/ui/conflict/resolution/file.lua new file mode 100644 index 00000000..9608a071 --- /dev/null +++ b/lua/codediff/ui/conflict/resolution/file.lua @@ -0,0 +1,209 @@ +-- Resolution commands for every conflict block in the current file. +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 apply_to_result = require("codediff.ui.conflict.resolution.replace").apply_to_result + +--- Accept ALL incoming (left/input1) for all active conflicts +--- @param tabpage number +--- @return boolean success +function M.accept_all_incoming(tabpage) + local session = lifecycle.get_session(tabpage) + if not session then + vim.notify("[codediff] No active session", vim.log.levels.WARN) + return false + end + + if not session.conflict_blocks or #session.conflict_blocks == 0 then + vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) + return false + end + + local result_bufnr = session.result_bufnr + local base_lines = session.result_base_lines + if not result_bufnr or not base_lines then + vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) + return false + end + + local count = 0 + + -- Process blocks in REVERSE order (bottom-to-top) to avoid line offset issues + -- Wrap in undojoin for atomic undo + vim.api.nvim_buf_call(result_bufnr, function() + for i = #session.conflict_blocks, 1, -1 do + local block = session.conflict_blocks[i] + if tracking.is_block_active(session, block) then + if count > 0 then + pcall(vim.cmd, "undojoin") + end + local incoming_lines = tracking.get_lines_for_range(session.original_bufnr, block.output1_range.start_line, block.output1_range.end_line) + apply_to_result(result_bufnr, block, incoming_lines, base_lines) + count = count + 1 + end + end + end) + + signs.refresh_all_conflict_signs(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 +end + +--- Accept ALL current (right/input2) for all active conflicts +--- @param tabpage number +--- @return boolean success +function M.accept_all_current(tabpage) + local session = lifecycle.get_session(tabpage) + if not session then + vim.notify("[codediff] No active session", vim.log.levels.WARN) + return false + end + + if not session.conflict_blocks or #session.conflict_blocks == 0 then + vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) + return false + end + + local result_bufnr = session.result_bufnr + local base_lines = session.result_base_lines + if not result_bufnr or not base_lines then + vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) + return false + end + + local count = 0 + + vim.api.nvim_buf_call(result_bufnr, function() + for i = #session.conflict_blocks, 1, -1 do + local block = session.conflict_blocks[i] + if tracking.is_block_active(session, block) then + if count > 0 then + pcall(vim.cmd, "undojoin") + end + local current_lines = tracking.get_lines_for_range(session.modified_bufnr, block.output2_range.start_line, block.output2_range.end_line) + apply_to_result(result_bufnr, block, current_lines, base_lines) + count = count + 1 + end + end + end) + + signs.refresh_all_conflict_signs(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 +end + +--- Accept ALL both sides for all active conflicts +--- @param tabpage number +--- @param first_input number|nil Which input comes first (1=incoming, 2=current). Default: 1 +--- @return boolean success +function M.accept_all_both(tabpage, first_input) + first_input = first_input or 1 + + local session = lifecycle.get_session(tabpage) + if not session then + vim.notify("[codediff] No active session", vim.log.levels.WARN) + return false + end + + if not session.conflict_blocks or #session.conflict_blocks == 0 then + vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) + return false + end + + local result_bufnr = session.result_bufnr + local base_lines = session.result_base_lines + if not result_bufnr or not base_lines then + vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) + return false + end + + local count = 0 + + vim.api.nvim_buf_call(result_bufnr, function() + for i = #session.conflict_blocks, 1, -1 do + local block = session.conflict_blocks[i] + if tracking.is_block_active(session, block) then + if count > 0 then + pcall(vim.cmd, "undojoin") + end + + local incoming_lines = tracking.get_lines_for_range(session.original_bufnr, block.output1_range.start_line, block.output1_range.end_line) + local current_lines = tracking.get_lines_for_range(session.modified_bufnr, block.output2_range.start_line, block.output2_range.end_line) + + -- Combine both sides + local combined + if first_input == 1 then + combined = vim.list_extend(vim.list_extend({}, incoming_lines), current_lines) + else + combined = vim.list_extend(vim.list_extend({}, current_lines), incoming_lines) + end + + apply_to_result(result_bufnr, block, combined, base_lines) + count = count + 1 + end + end + end) + + signs.refresh_all_conflict_signs(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 +end + +--- Discard ALL changes (reset all conflicts to base) +--- @param tabpage number +--- @return boolean success +function M.discard_all(tabpage) + local session = lifecycle.get_session(tabpage) + if not session then + vim.notify("[codediff] No active session", vim.log.levels.WARN) + return false + end + + if not session.conflict_blocks or #session.conflict_blocks == 0 then + vim.notify("[codediff] No conflicts in this session", vim.log.levels.WARN) + return false + end + + local result_bufnr = session.result_bufnr + local seed_lines = session.result_base_lines + -- Use the true merge base for the slice we write back; the seed only feeds + -- the content-search fallback in apply_to_result. + local base_lines = session.merge_base_lines or seed_lines + if not result_bufnr or not seed_lines or not base_lines then + vim.notify("[codediff] No result buffer or base lines", vim.log.levels.ERROR) + return false + end + + local count = 0 + + vim.api.nvim_buf_call(result_bufnr, function() + for i = #session.conflict_blocks, 1, -1 do + local block = session.conflict_blocks[i] + -- For discard, we reset even resolved conflicts back to base + if count > 0 then + pcall(vim.cmd, "undojoin") + end + + local base_content = {} + for j = block.base_range.start_line, block.base_range.end_line - 1 do + table.insert(base_content, base_lines[j] or "") + end + + apply_to_result(result_bufnr, block, base_content, seed_lines) + count = count + 1 + end + end) + + signs.refresh_all_conflict_signs(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 +end + +return M diff --git a/lua/codediff/ui/conflict/resolution/init.lua b/lua/codediff/ui/conflict/resolution/init.lua new file mode 100644 index 00000000..731f501c --- /dev/null +++ b/lua/codediff/ui/conflict/resolution/init.lua @@ -0,0 +1,19 @@ +-- Conflict resolution commands for one block or a whole file. +local M = {} + +local block = require("codediff.ui.conflict.resolution.block") +local file = require("codediff.ui.conflict.resolution.file") +local diffget = require("codediff.ui.conflict.resolution.diffget") + +M.accept_incoming = block.accept_incoming +M.accept_current = block.accept_current +M.accept_both = block.accept_both +M.discard = block.discard +M.accept_all_incoming = file.accept_all_incoming +M.accept_all_current = file.accept_all_current +M.accept_all_both = file.accept_all_both +M.discard_all = file.discard_all +M.diffget_incoming = diffget.diffget_incoming +M.diffget_current = diffget.diffget_current + +return M diff --git a/lua/codediff/ui/conflict/resolution/replace.lua b/lua/codediff/ui/conflict/resolution/replace.lua new file mode 100644 index 00000000..efabfab8 --- /dev/null +++ b/lua/codediff/ui/conflict/resolution/replace.lua @@ -0,0 +1,73 @@ +-- Replaces a tracked conflict range in the Result buffer. +local M = {} + +local tracking = require("codediff.ui.conflict.tracking") + +--- Apply text to result buffer at the conflict's range +--- @param result_bufnr number Result buffer +--- @param block table Conflict block with base_range and optional extmark_id +--- @param lines table Lines to insert +--- @param base_lines table Result-buffer seed content (auto-merged result), +--- used only for the content-search fallback when the +--- extmark is invalid. Indexed by result_range, falling +--- back to base_range for legacy paths. +function M.apply_to_result(result_bufnr, block, lines, base_lines) + local start_row, end_row + + -- Method 1: Try using extmarks (robust against edits) + if block.extmark_id then + local mark = vim.api.nvim_buf_get_extmark_by_id(result_bufnr, tracking.tracking_ns, block.extmark_id, { details = true }) + if mark and #mark >= 3 then + start_row = mark[1] + end_row = mark[3].end_row + end + end + + -- Method 2: Fallback to content search or original range + if not start_row then + -- The result buffer seed (base_lines here) is the auto-merged Result, so + -- the slice for this conflict lives at result_range (which equals the + -- original BASE slice for unresolved conflict regions). Fall back to + -- base_range for legacy callers that never set result_range. + local range = block.result_range or block.base_range + -- For simplicity, we'll re-apply based on content matching + -- Find the seed slice in the result buffer + local base_content = {} + for i = range.start_line, range.end_line - 1 do + table.insert(base_content, base_lines[i] or "") + end + + local result_lines = vim.api.nvim_buf_get_lines(result_bufnr, 0, -1, false) + + -- Search for the seed content in result buffer + local found_start = nil + for i = 1, #result_lines - #base_content + 1 do + local match = true + for j = 1, #base_content do + if result_lines[i + j - 1] ~= base_content[j] then + match = false + break + end + end + if match then + found_start = i + break + end + end + + if found_start then + start_row = found_start - 1 + end_row = found_start - 1 + #base_content + else + -- Fallback: try to find by approximate position + start_row = math.min(range.start_line - 1, #result_lines) + end_row = math.min(range.end_line - 1, #result_lines) + end + end + + if start_row and end_row then + vim.api.nvim_buf_set_lines(result_bufnr, start_row, end_row, false, lines) + end +end + +return M diff --git a/lua/codediff/ui/conflict/view/init.lua b/lua/codediff/ui/conflict/view/init.lua new file mode 100644 index 00000000..cf0aaf61 --- /dev/null +++ b/lua/codediff/ui/conflict/view/init.lua @@ -0,0 +1,10 @@ +-- Three-pane conflict editor rendering. +local M = {} + +local inputs = require("codediff.ui.conflict.view.inputs") +local result = require("codediff.ui.conflict.view.result") + +M.compute_and_render_conflict = inputs.compute_and_render_conflict +M.setup_conflict_result_window = result.setup_conflict_result_window + +return M diff --git a/lua/codediff/ui/conflict/view/inputs.lua b/lua/codediff/ui/conflict/view/inputs.lua new file mode 100644 index 00000000..852850ab --- /dev/null +++ b/lua/codediff/ui/conflict/view/inputs.lua @@ -0,0 +1,95 @@ +-- Renders the two conflict input panes against their merge base. +local M = {} + +local core = require("codediff.ui.core") +local semantic = require("codediff.ui.semantic_tokens") +local config = require("codediff.config") +local diff_module = require("codediff.core.diff") + +-- Conflict mode rendering: Both buffers show diff against base with alignment +-- Left buffer (:3: theirs/incoming) and Right buffer (:2: ours/current) +-- Both show green highlights indicating changes from base (:1:) +-- Filler lines are inserted to align corresponding changes +-- @param original_buf number: Left buffer (incoming :3:) +-- @param modified_buf number: Right buffer (current :2:) +-- @param base_lines table: Base content (:1:) +-- @param original_lines table: Incoming content (:3:) +-- @param modified_lines table: Current content (:2:) +-- @param original_win number: Left window +-- @param modified_win number: Right window +-- @param auto_scroll_to_first_hunk boolean: Whether to scroll to first change +-- @return table: { base_to_original_diff, base_to_modified_diff } +function M.compute_and_render_conflict(original_buf, modified_buf, base_lines, original_lines, modified_lines, original_win, modified_win, auto_scroll_to_first_hunk) + local diff_options = { + max_computation_time_ms = config.options.diff.max_computation_time_ms, + ignore_trim_whitespace = config.options.diff.ignore_trim_whitespace, + compute_moves = config.options.diff.compute_moves, + } + + -- Compute base -> original (incoming) diff + local base_to_original_diff = diff_module.compute_diff(base_lines, original_lines, diff_options) + if not base_to_original_diff then + vim.notify("Failed to compute base->incoming diff", vim.log.levels.ERROR) + return nil + end + + -- Compute base -> modified (current) diff + local base_to_modified_diff = diff_module.compute_diff(base_lines, modified_lines, diff_options) + if not base_to_modified_diff then + vim.notify("Failed to compute base->current diff", vim.log.levels.ERROR) + return nil + end + + -- Render merge view with alignment and filler lines + local render_result = core.render_merge_view(original_buf, modified_buf, base_to_original_diff, base_to_modified_diff, base_lines, original_lines, modified_lines) + + -- Apply semantic tokens (both are virtual buffers in conflict mode) + semantic.apply_semantic_tokens(original_buf, modified_buf) + semantic.apply_semantic_tokens(modified_buf, original_buf) + + -- Setup window options with structural scroll-sync (filler lines enable proper alignment) + if original_win and modified_win and vim.api.nvim_win_is_valid(original_win) and vim.api.nvim_win_is_valid(modified_win) then + vim.wo[original_win].wrap = false + vim.wo[modified_win].wrap = false + + -- Reset scroll position and bind the two panes (the result pane, if any, + -- is added to the group later once it exists). + vim.api.nvim_win_set_cursor(original_win, { 1, 0 }) + vim.api.nvim_win_set_cursor(modified_win, { 1, 0 }) + local scroll = require("codediff.ui.scroll") + local tabpage = vim.api.nvim_win_get_tabpage(modified_win) + scroll.bind(tabpage, { original_win, modified_win }) + scroll.resync(tabpage, modified_win) + + -- Scroll to first change in either buffer + if auto_scroll_to_first_hunk then + local first_line = nil + if #base_to_original_diff.changes > 0 then + first_line = base_to_original_diff.changes[1].modified.start_line + elseif #base_to_modified_diff.changes > 0 then + first_line = base_to_modified_diff.changes[1].modified.start_line + end + + if first_line then + pcall(vim.api.nvim_win_set_cursor, original_win, { first_line, 0 }) + pcall(vim.api.nvim_win_set_cursor, modified_win, { first_line, 0 }) + if vim.api.nvim_win_is_valid(modified_win) then + vim.api.nvim_set_current_win(modified_win) + vim.cmd("normal! zz") + end + end + end + end + + return { + base_to_original_diff = base_to_original_diff, + base_to_modified_diff = base_to_modified_diff, + conflict_blocks = render_result and render_result.conflict_blocks or {}, + -- Pass through per-side content so Result can be auto-merged without + -- re-fetching buffers. + original_lines = original_lines, + modified_lines = modified_lines, + } +end + +return M diff --git a/lua/codediff/ui/view/conflict_window.lua b/lua/codediff/ui/conflict/view/result.lua similarity index 99% rename from lua/codediff/ui/view/conflict_window.lua rename to lua/codediff/ui/conflict/view/result.lua index d8375497..49c23905 100644 --- a/lua/codediff/ui/view/conflict_window.lua +++ b/lua/codediff/ui/conflict/view/result.lua @@ -102,7 +102,7 @@ function M.setup_conflict_result_window(tabpage, session_config, original_win, m -- conflict_diffs.conflict_blocks (from compute_merge_fillers_and_conflicts) is -- the visual filler list for the side panes; the Result-buffer-oriented blocks -- (with result_range) come from compute_auto_merged_result. - local merge_alignment = require("codediff.ui.merge_alignment") + local merge_alignment = require("codediff.ui.conflict.merge") local result_lines, result_conflict_blocks = merge_alignment.compute_auto_merged_result( conflict_diffs.base_to_original_diff, conflict_diffs.base_to_modified_diff, diff --git a/lua/codediff/ui/core.lua b/lua/codediff/ui/core.lua index b17bd324..44bad115 100644 --- a/lua/codediff/ui/core.lua +++ b/lua/codediff/ui/core.lua @@ -458,7 +458,7 @@ end -- left_lines_content: array of input1 content lines -- right_lines_content: array of input2 content lines function M.render_merge_view(left_bufnr, right_bufnr, base_to_left_diff, base_to_right_diff, base_lines, left_lines_content, right_lines_content) - local merge_alignment = require("codediff.ui.merge_alignment") + local merge_alignment = require("codediff.ui.conflict.merge") -- Clear existing highlights and fillers vim.api.nvim_buf_clear_namespace(left_bufnr, ns_highlight, 0, -1) diff --git a/lua/codediff/ui/explorer/refresh.lua b/lua/codediff/ui/explorer/refresh/init.lua similarity index 76% rename from lua/codediff/ui/explorer/refresh.lua rename to lua/codediff/ui/explorer/refresh/init.lua index dc275d33..2924f5cb 100644 --- a/lua/codediff/ui/explorer/refresh.lua +++ b/lua/codediff/ui/explorer/refresh/init.lua @@ -4,161 +4,11 @@ local M = {} local config = require("codediff.config") local tree_module = require("codediff.ui.explorer.tree") local welcome = require("codediff.ui.welcome") +local scheduler = require("codediff.ui.explorer.refresh.scheduler") + -- Setup native repository watching with polling as startup/runtime fallback. function M.setup_auto_refresh(explorer, tabpage) - local explorer_config = config.options.explorer or {} - local uv = vim.uv or vim.loop - local poll_timer - local unsubscribe - local cleaned = false - local refresh_running = false - local refresh_pending = false - local pending_force = false - local pending_done = {} - local group - - local function stop_polling() - if not poll_timer then - return - end - pcall(function() - poll_timer:stop() - end) - pcall(function() - poll_timer:close() - end) - poll_timer = nil - end - - local function cleanup() - if cleaned then - return - end - cleaned = true - stop_polling() - if unsubscribe then - unsubscribe() - unsubscribe = nil - end - pending_done = {} - explorer._request_refresh = nil - explorer._request_auto_refresh = nil - explorer._native_watcher_ready = nil - if group then - pcall(vim.api.nvim_del_augroup_by_id, group) - end - end - - explorer._cleanup_auto_refresh = cleanup - - local function call_done(callbacks) - for _, callback in ipairs(callbacks) do - pcall(callback) - end - end - - local request_refresh - request_refresh = function(force, done_callbacks) - done_callbacks = done_callbacks or {} - if cleaned then - return - end - if refresh_running then - refresh_pending = true - pending_force = pending_force or force == true - vim.list_extend(pending_done, done_callbacks) - return - end - refresh_running = true - M._refresh_once(explorer, function() - if cleaned then - return - end - require("codediff.ui.auto_refresh").sync_mutable_buffers(tabpage, function() - if cleaned then - return - end - refresh_running = false - call_done(done_callbacks) - if refresh_pending then - local force_pending = pending_force - local done_pending = pending_done - refresh_pending = false - pending_force = false - pending_done = {} - request_refresh(force_pending, done_pending) - end - end) - end, force) - end - - local function tick(force) - if not vim.api.nvim_tabpage_is_valid(tabpage) or explorer.is_hidden then - return - end - -- A queued fallback tick may outlive the tab or repository setup/teardown. - local git_root = explorer.git_root - if git_root and git_root ~= "" then - if vim.fn.isdirectory(git_root) == 0 then - return - end - -- Linked worktrees and submodules use a .git pointer file. - local dot_git = git_root .. "/.git" - if vim.fn.isdirectory(dot_git) == 0 and vim.fn.filereadable(dot_git) == 0 then - return - end - end - request_refresh(force) - end - - explorer._request_refresh = function(force, done) - request_refresh(force, done and { done } or {}) - end - explorer._request_auto_refresh = function() - tick(true) - end - - if explorer_config.auto_refresh == false then - return cleanup - end - - group = vim.api.nvim_create_augroup("CodeDiffExplorerRefresh_" .. tabpage, { clear = true }) - - local function start_polling() - if cleaned or poll_timer then - return - end - poll_timer = uv.new_timer() - if poll_timer then - poll_timer:start(500, 500, vim.schedule_wrap(tick)) - end - end - - start_polling() - if explorer.git_root and explorer.git_root ~= "" then - unsubscribe = require("codediff.core.watcher").subscribe(explorer.git_root, { - on_ready = function() - explorer._native_watcher_ready = true - stop_polling() - tick(true) - end, - on_refresh = function() - tick(true) - end, - on_error = function() - explorer._native_watcher_ready = false - start_polling() - end, - }) - end - - vim.api.nvim_create_autocmd("TabClosed", { - group = group, - pattern = tostring(tabpage), - callback = cleanup, - }) - - return cleanup + return scheduler.setup(explorer, tabpage, M._refresh_once) end --- Walk every group and directory node beneath `root_nodes`, calling `visit` diff --git a/lua/codediff/ui/explorer/refresh/scheduler.lua b/lua/codediff/ui/explorer/refresh/scheduler.lua new file mode 100644 index 00000000..75851b2c --- /dev/null +++ b/lua/codediff/ui/explorer/refresh/scheduler.lua @@ -0,0 +1,162 @@ +-- Serializes complete Explorer refresh cycles across every request source. +local M = {} + +local config = require("codediff.config") + +function M.setup(explorer, tabpage, refresh_once) + local explorer_config = config.options.explorer or {} + local uv = vim.uv or vim.loop + local poll_timer + local unsubscribe + local cleaned = false + local refresh_running = false + local refresh_pending = false + local pending_force = false + local pending_done = {} + local group + + local function stop_polling() + if not poll_timer then + return + end + pcall(function() + poll_timer:stop() + end) + pcall(function() + poll_timer:close() + end) + poll_timer = nil + end + + local function cleanup() + if cleaned then + return + end + cleaned = true + stop_polling() + if unsubscribe then + unsubscribe() + unsubscribe = nil + end + pending_done = {} + explorer._request_refresh = nil + explorer._request_auto_refresh = nil + explorer._native_watcher_ready = nil + if group then + pcall(vim.api.nvim_del_augroup_by_id, group) + end + end + + explorer._cleanup_auto_refresh = cleanup + + local function call_done(callbacks) + for _, callback in ipairs(callbacks) do + pcall(callback) + end + end + + local request_refresh + request_refresh = function(force, done_callbacks) + done_callbacks = done_callbacks or {} + if cleaned then + return + end + if refresh_running then + refresh_pending = true + pending_force = pending_force or force == true + vim.list_extend(pending_done, done_callbacks) + return + end + refresh_running = true + refresh_once(explorer, function() + if cleaned then + return + end + require("codediff.ui.auto_refresh").sync_mutable_buffers(tabpage, function() + if cleaned then + return + end + refresh_running = false + call_done(done_callbacks) + if refresh_pending then + local force_pending = pending_force + local done_pending = pending_done + refresh_pending = false + pending_force = false + pending_done = {} + request_refresh(force_pending, done_pending) + end + end) + end, force) + end + + local function tick(force) + if not vim.api.nvim_tabpage_is_valid(tabpage) or explorer.is_hidden then + return + end + -- A queued fallback tick may outlive the tab or repository setup/teardown. + local git_root = explorer.git_root + if git_root and git_root ~= "" then + if vim.fn.isdirectory(git_root) == 0 then + return + end + -- Linked worktrees and submodules use a .git pointer file. + local dot_git = git_root .. "/.git" + if vim.fn.isdirectory(dot_git) == 0 and vim.fn.filereadable(dot_git) == 0 then + return + end + end + request_refresh(force) + end + + explorer._request_refresh = function(force, done) + request_refresh(force, done and { done } or {}) + end + explorer._request_auto_refresh = function() + tick(true) + end + + if explorer_config.auto_refresh == false then + return cleanup + end + + group = vim.api.nvim_create_augroup("CodeDiffExplorerRefresh_" .. tabpage, { clear = true }) + + local function start_polling() + if cleaned or poll_timer then + return + end + poll_timer = uv.new_timer() + if poll_timer then + poll_timer:start(500, 500, vim.schedule_wrap(tick)) + end + end + + start_polling() + if explorer.git_root and explorer.git_root ~= "" then + unsubscribe = require("codediff.core.watcher").subscribe(explorer.git_root, { + on_ready = function() + explorer._native_watcher_ready = true + stop_polling() + tick(true) + end, + on_refresh = function() + tick(true) + end, + on_error = function() + explorer._native_watcher_ready = false + start_polling() + end, + }) + end + + vim.api.nvim_create_autocmd("TabClosed", { + group = group, + pattern = tostring(tabpage), + callback = cleanup, + }) + + return cleanup +end + +return M diff --git a/lua/codediff/ui/explorer/render.lua b/lua/codediff/ui/explorer/render.lua index c06d43ce..5141b87e 100644 --- a/lua/codediff/ui/explorer/render.lua +++ b/lua/codediff/ui/explorer/render.lua @@ -75,7 +75,7 @@ local function open_diff_when_still_selected(ctx, sides) end --- conflict_ours_position names where OURS sits on screen; original_win is on ---- the left after conflict_window.lua's win_splitmove(rightbelow=false). +--- the left after the conflict view's win_splitmove(rightbelow=false). --- @return string original_rev, string modified_rev local function conflict_revisions() if (config.options.diff.conflict_ours_position or "right") == "right" then diff --git a/lua/codediff/ui/lifecycle/session.lua b/lua/codediff/ui/lifecycle/session.lua index f93bd6d8..e19a1f14 100644 --- a/lua/codediff/ui/lifecycle/session.lua +++ b/lua/codediff/ui/lifecycle/session.lua @@ -158,7 +158,7 @@ function M.create_session(tabpage, session_config, panes) -- Force disable winbar to prevent alignment issues (except in conflict mode) local function sync_window_ui(sess, win) - -- In conflict mode, preserve existing winbar titles (set by conflict_window.lua) + -- In conflict mode, preserve existing winbar titles set by the conflict view. if sess and sess.result_win and vim.api.nvim_win_is_valid(sess.result_win) then return end diff --git a/lua/codediff/ui/view/inline_view.lua b/lua/codediff/ui/view/inline_view.lua deleted file mode 100644 index 2dbcbe29..00000000 --- a/lua/codediff/ui/view/inline_view.lua +++ /dev/null @@ -1,718 +0,0 @@ --- Inline diff view engine: single-window diff with virtual line overlays --- Parallel to side_by_side.lua — handles creation, updating, and re-rendering -local M = {} - -local lifecycle = require("codediff.ui.lifecycle") -local auto_refresh = require("codediff.ui.auto_refresh") -local config = require("codediff.config") -local core = require("codediff.ui.core") -local path = require("codediff.core.path") -local diff_module = require("codediff.core.diff") -local inline = require("codediff.ui.inline") -local semantic = require("codediff.ui.semantic_tokens") -local layout = require("codediff.ui.layout") -local welcome_window = require("codediff.ui.view.welcome_window") - -local helpers = require("codediff.ui.view.helpers") -local readiness = require("codediff.ui.view.readiness") -local panel = require("codediff.ui.view.panel") -local is_virtual_revision = helpers.is_virtual_revision -local prepare_buffer = helpers.prepare_buffer -local is_panel_placeholder = helpers.is_panel_placeholder -local show_real_file_buffer = helpers.show_real_file_buffer -local open_real_file = helpers.open_real_file - -local function disable_refresh_and_clear_highlights(session) - for _, bufnr in pairs({ session.original_bufnr, session.modified_bufnr }) do - if vim.api.nvim_buf_is_valid(bufnr) then - auto_refresh.disable(bufnr) - lifecycle.clear_highlights(bufnr) - end - end -end - --- ============================================================================ --- Compute diff and render inline highlights --- ============================================================================ - -local function compute_and_render_inline( - modified_buf, - original_buf, - original_lines, - modified_lines, - original_is_virtual, - modified_is_virtual, - modified_win, - auto_scroll_to_first_hunk -) - local diff_options = { - max_computation_time_ms = config.options.diff.max_computation_time_ms, - ignore_trim_whitespace = config.options.diff.ignore_trim_whitespace, - compute_moves = config.options.diff.compute_moves, - } - - local lines_diff = diff_module.compute_diff(original_lines, modified_lines, diff_options) - if not lines_diff then - vim.notify("Failed to compute diff", vim.log.levels.ERROR) - return nil - end - - inline.render_inline_diff(modified_buf, lines_diff, original_lines, modified_lines) - - if original_is_virtual then - semantic.apply_semantic_tokens(original_buf, modified_buf) - end - if modified_is_virtual then - semantic.apply_semantic_tokens(modified_buf, original_buf) - end - - if modified_win and vim.api.nvim_win_is_valid(modified_win) then - vim.wo[modified_win].wrap = false - if auto_scroll_to_first_hunk and lines_diff.changes and #lines_diff.changes > 0 then - -- Honor session.pending_cursor_landing (cycle-hunks-across-files - -- backward direction sets it to "last"; see ui/view/navigation.lua). - -- Look up the session via the window's tabpage because this code can - -- run from a scheduled callback on a different tab. - local lifecycle = require("codediff.ui.lifecycle") - local tabpage = vim.api.nvim_win_get_tabpage(modified_win) - local session = tabpage and lifecycle.get_session(tabpage) or nil - local landing = session and session.pending_cursor_landing - if session then - session.pending_cursor_landing = nil - end - - local target_line = landing == "last" and lines_diff.changes[#lines_diff.changes].modified.start_line or lines_diff.changes[1].modified.start_line - pcall(vim.api.nvim_win_set_cursor, modified_win, { target_line, 0 }) - vim.api.nvim_set_current_win(modified_win) - vim.cmd("normal! zz") - end - end - - return lines_diff -end - --- Helper: mark session as inline layout after creation -local function mark_inline(tabpage) - lifecycle.update_layout(tabpage, "inline") -end - --- Helper: setup keymaps (uses the shared setup_all_keymaps which is layout-aware) -local function setup_keymaps(tabpage, orig_buf, mod_buf) - local view_keymaps = require("codediff.ui.view.keymaps") - local session = lifecycle.get_session(tabpage) - local is_explorer = session and session.panel ~= nil and session.panel.name == "explorer" - view_keymaps.setup_all_keymaps(tabpage, orig_buf, mod_buf, is_explorer) -end - --- ============================================================================ --- Create --- ============================================================================ - ---- Replace a scratch buffer's contents, restoring its read-only state. ---- Returns false when the buffer is gone, which is the caller's cue to stop: ---- the tab may have closed while the fetch was in flight. ---- @param bufnr number ---- @param lines string[] ---- @return boolean -local function set_scratch_lines(bufnr, lines) - if not vim.api.nvim_buf_is_valid(bufnr) then - return false - end - vim.bo[bufnr].modifiable = true - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) - vim.bo[bufnr].modifiable = false - return true -end - ---- An empty, unlisted, non-file buffer. ---- @return number -local function new_scratch() - local bufnr = vim.api.nvim_create_buf(false, true) - vim.bo[bufnr].buftype = "nofile" - return bufnr -end - ---- Options for the single inline pane. No 'list' here: side-by-side sets it ---- to keep the two panes visually identical, which does not apply to one pane. ---- @param win number -local function apply_pane_options(win) - vim.wo[win].cursorline = true - vim.wo[win].wrap = false -end - ---- Reapply-keymaps callback stored on the session. ---- @param tabpage number ---- @param original_bufnr number ---- @return function -local function make_reapply_keymaps(tabpage, original_bufnr) - return function() - local _, mb = lifecycle.get_buffers(tabpage) - if mb then - setup_keymaps(tabpage, original_bufnr, mb) - end - end -end - ---- Attach the panels, lay the tab out, announce the view, and describe it. ---- @param tabpage number ---- @param session_config SessionConfig ---- @param modified_win number ---- @param original_bufnr number ---- @param modified_bufnr number ---- @return table -local function finish_create(tabpage, session_config, modified_win, original_bufnr, modified_bufnr) - panel.setup_explorer(tabpage, session_config, modified_win, modified_win) - panel.setup_history(tabpage, session_config, modified_win, modified_win) - - layout.arrange(tabpage) - - vim.api.nvim_exec_autocmds("User", { - pattern = "CodeDiffOpen", - modeline = false, - data = { tabpage = tabpage, mode = lifecycle.event_mode(session_config.panel), layout = "inline" }, - }) - - return { modified_buf = modified_bufnr, original_buf = original_bufnr, modified_win = modified_win } -end - ---- Open the single pane with a scratch buffer, for a session whose content ---- arrives later via the panel. The hidden original side gets a scratch buffer ---- too, so the session always has two buffers to talk about. ---- @param tabpage number ---- @param modified_win number ---- @return number original_bufnr, number modified_bufnr -local function open_placeholder_pane(tabpage, modified_win) - local mod_scratch = new_scratch() - pcall(vim.api.nvim_buf_set_name, mod_scratch, "CodeDiff " .. tabpage .. ".inline") - vim.api.nvim_win_set_buf(modified_win, mod_scratch) - welcome_window.sync(modified_win) - - return new_scratch(), mod_scratch -end - ---- Show the modified side in the visible pane. ---- @param win number ---- @param info table From prepare_buffer; info.bufnr is updated in place ---- @param is_virtual boolean -local function load_visible_side(win, info, is_virtual) - if is_virtual then - if info.needs_edit then - vim.cmd("edit! " .. vim.fn.fnameescape(info.target)) - info.bufnr = vim.api.nvim_get_current_buf() - else - vim.api.nvim_win_set_buf(win, info.bufnr) - end - elseif info.needs_edit then - info.bufnr = open_real_file(win, info.target) - else - show_real_file_buffer(win, info.bufnr) - end - welcome_window.sync(win) -end - ---- Materialise the original side, which inline never puts in a window. ---- A codediff:// buffer carries bufhidden=wipe, so with no window showing it ---- :edit would destroy it at once; it gets a scratch buffer instead. ---- @param info table From prepare_buffer; info.bufnr is updated in place ---- @param is_virtual boolean -local function load_hidden_original(info, is_virtual) - if is_virtual and info.needs_edit then - info.bufnr = new_scratch() - elseif info.needs_edit then - local bufnr = vim.fn.bufadd(info.target) - vim.fn.bufload(bufnr) - info.bufnr = bufnr - end -end - ---- Call `render` once the modified buffer's virtual content has loaded. ---- @param tabpage number ---- @param modified_bufnr number ---- @param render function -local function render_after_modified_loads(tabpage, modified_bufnr, render) - local group = vim.api.nvim_create_augroup("CodeDiffInlineVirtualLoad_" .. tabpage, { clear = true }) - vim.api.nvim_create_autocmd("User", { - group = group, - pattern = "CodeDiffVirtualFileLoaded", - callback = function(event) - if event.data and event.data.buf == modified_bufnr then - vim.schedule(render) - vim.api.nvim_del_augroup_by_id(group) - end - end, - }) -end - ---- Run `render` once both sides hold their content. ---- The original side, when virtual, is fetched here rather than through ---- BufReadCmd, because it has no window to trigger one. ---- @param ctx table { tabpage, session_config, original_info, modified_info, virtual flags } ---- @param render function -local function render_when_loaded(ctx, render) - local original_info, modified_info = ctx.original_info, ctx.modified_info - - if not ctx.original_is_virtual then - if ctx.modified_is_virtual then - render_after_modified_loads(ctx.tabpage, modified_info.bufnr, render) - else - vim.schedule(render) - end - return - end - - local git = require("codediff.core.git") - local session_config = ctx.session_config - git.get_file_content(session_config.original_revision, session_config.git_root, session_config.original.relative, function(err, lines) - vim.schedule(function() - if not set_scratch_lines(original_info.bufnr, err and {} or lines) then - return - end - - if ctx.modified_is_virtual then - render_after_modified_loads(ctx.tabpage, modified_info.bufnr, render) - else - render() - end - end) - end) -end - ----@param session_config SessionConfig ----@param filetype? string ----@param on_ready? function ----@return table|nil -function M.create(session_config, filetype, on_ready) - vim.cmd("tabnew") - local tabpage = vim.api.nvim_get_current_tabpage() - local modified_win = vim.api.nvim_get_current_win() - local initial_buf = vim.api.nvim_get_current_buf() - - --- Drop the tab's starting buffer once the real ones are in place. - local function drop_initial_buf(...) - for _, keep in ipairs({ ... }) do - if initial_buf == keep then - return - end - end - if vim.api.nvim_buf_is_valid(initial_buf) then - pcall(vim.api.nvim_buf_delete, initial_buf, { force = true }) - end - end - - if is_panel_placeholder(session_config) then - local orig_scratch, mod_scratch = open_placeholder_pane(tabpage, modified_win) - drop_initial_buf(mod_scratch) - apply_pane_options(modified_win) - - -- The panel populates this session on first file selection. - lifecycle.create_session(tabpage, session_config, { - original_bufnr = orig_scratch, - modified_bufnr = mod_scratch, - original_win = modified_win, - modified_win = modified_win, -- both point to the single window - lines_diff = {}, - reapply_keymaps = make_reapply_keymaps(tabpage, orig_scratch), - }) - - mark_inline(tabpage) - return finish_create(tabpage, session_config, modified_win, orig_scratch, mod_scratch) - end - - local original_is_virtual = is_virtual_revision(session_config.original_revision) - local modified_is_virtual = is_virtual_revision(session_config.modified_revision) - - local original_info = prepare_buffer(original_is_virtual, session_config.git_root, session_config.original_revision, session_config.original) - local modified_info = prepare_buffer(modified_is_virtual, session_config.git_root, session_config.modified_revision, session_config.modified) - - load_visible_side(modified_win, modified_info, modified_is_virtual) - load_hidden_original(original_info, original_is_virtual) - - drop_initial_buf(modified_info.bufnr, original_info.bufnr) - apply_pane_options(modified_win) - - local render = function() - if not vim.api.nvim_win_is_valid(modified_win) then - return - end - if not vim.api.nvim_buf_is_valid(original_info.bufnr) or not vim.api.nvim_buf_is_valid(modified_info.bufnr) then - return - end - - local lines_diff = compute_and_render_inline( - modified_info.bufnr, - original_info.bufnr, - vim.api.nvim_buf_get_lines(original_info.bufnr, 0, -1, false), - vim.api.nvim_buf_get_lines(modified_info.bufnr, 0, -1, false), - original_is_virtual, - modified_is_virtual, - modified_win, - config.options.diff.jump_to_first_change - ) - if not lines_diff then - return - end - - lifecycle.create_session(tabpage, session_config, { - original_bufnr = original_info.bufnr, - modified_bufnr = modified_info.bufnr, - original_win = modified_win, - modified_win = modified_win, - lines_diff = lines_diff, - reapply_keymaps = make_reapply_keymaps(tabpage, original_info.bufnr), - }) - - mark_inline(tabpage) - - auto_refresh.enable(original_info.bufnr) - auto_refresh.enable(modified_info.bufnr) - - setup_keymaps(tabpage, original_info.bufnr, modified_info.bufnr) - - -- Keep the diff pointed at the working window's file if it changes. Same - -- as the side-by-side path: the behaviour belongs to the session shape, - -- not to a layout. - require("codediff.ui.follow_working_file").enable(tabpage, original_is_virtual, modified_is_virtual) - - if on_ready then - on_ready() - end - end - - render_when_loaded({ - tabpage = tabpage, - session_config = session_config, - original_info = original_info, - modified_info = modified_info, - original_is_virtual = original_is_virtual, - modified_is_virtual = modified_is_virtual, - }, render) - - return finish_create(tabpage, session_config, modified_win, original_info.bufnr, modified_info.bufnr) -end - --- ============================================================================ --- Update (for explorer/history file switching) --- ============================================================================ - ---- Fetch a revision from git into a scratch buffer, then signal completion. ---- Signals nothing if the buffer died while the fetch was in flight. ---- @param revision string ---- @param git_root string ---- @param relative string ---- @param bufnr number ---- @param done function -local function fetch_into_scratch(revision, git_root, relative, bufnr, done) - require("codediff.core.git").get_file_content(revision, git_root, relative, function(err, lines) - vim.schedule(function() - if set_scratch_lines(bufnr, err and {} or lines) then - done() - end - end) - end) -end - ---- Put the modified side in the pane. ---- Unlike create, a virtual revision goes into a scratch buffer rather than a ---- codediff:// URI, so retargeting never races a pending BufReadCmd. ---- @param win number ---- @param session_config SessionConfig ---- @param is_virtual boolean ---- @return number bufnr -local function open_modified_for_update(win, session_config, is_virtual) - if is_virtual then - local mod_buf = new_scratch() - vim.bo[mod_buf].modifiable = true - vim.api.nvim_win_set_buf(win, mod_buf) - local ft = vim.filetype.match({ filename = session_config.modified.absolute }) - if ft then - vim.bo[mod_buf].filetype = ft - end - return mod_buf - end - - local info = prepare_buffer(false, session_config.git_root, nil, session_config.modified) - if info.needs_edit then - return open_real_file(win, info.target) - end - show_real_file_buffer(win, info.bufnr) - return info.bufnr -end - ---- Fill the hidden original side. A real file is copied in synchronously; a ---- revision is fetched and lands through `done`. ---- @param orig_buf number ---- @param session_config SessionConfig ---- @param is_virtual boolean ---- @param done function Called when an async fetch lands -local function fill_original_for_update(orig_buf, session_config, is_virtual, done) - if is_virtual then - -- Retargeting can leave the original path empty (a file added in the - -- modified revision), so fall back to the modified side's path. - local relative = (session_config.original.relative ~= "" and session_config.original.relative) or session_config.modified.relative - fetch_into_scratch(session_config.original_revision, session_config.git_root, relative, orig_buf, done) - return - end - - local orig_path = (session_config.original.absolute ~= "" and session_config.original.absolute) or session_config.modified.absolute - if orig_path and orig_path ~= "" then - local real_bufnr = vim.fn.bufadd(orig_path) - vim.fn.bufload(real_bufnr) - set_scratch_lines(orig_buf, vim.api.nvim_buf_get_lines(real_bufnr, 0, -1, false)) - end -end - ---- Point the session at the newly computed diff and re-arm everything hanging ---- off it: refresh, keymaps, layout, and the window the user was in. ---- @param tabpage number ---- @param session_config SessionConfig ---- @param orig_buf number ---- @param mod_buf number ---- @param lines_diff table ---- @param saved_current_win number? -local function commit_update(tabpage, session_config, orig_buf, mod_buf, lines_diff, saved_current_win) - lifecycle.update_buffers(tabpage, orig_buf, mod_buf) - lifecycle.update_git_root(tabpage, session_config.git_root) - lifecycle.update_revisions(tabpage, session_config.original_revision, session_config.modified_revision) - lifecycle.update_diff_result(tabpage, lines_diff) - lifecycle.update_changedtick(tabpage, vim.api.nvim_buf_get_changedtick(orig_buf), vim.api.nvim_buf_get_changedtick(mod_buf)) - lifecycle.update_paths(tabpage, session_config.original, session_config.modified) - - auto_refresh.enable(orig_buf) - auto_refresh.enable(mod_buf) - - setup_keymaps(tabpage, orig_buf, mod_buf) - layout.arrange(tabpage) - - if saved_current_win and vim.api.nvim_win_is_valid(saved_current_win) then - vim.api.nvim_set_current_win(saved_current_win) - end -end - ----@param tabpage number ----@param session_config SessionConfig ----@param auto_scroll_to_first_hunk boolean? ----@return boolean -function M.update(tabpage, session_config, auto_scroll_to_first_hunk) - local saved_current_win = vim.api.nvim_get_current_win() - - local session = lifecycle.get_session(tabpage) - if not session then - return false - end - - local modified_win = session.modified_win - if not modified_win or not vim.api.nvim_win_is_valid(modified_win) then - return false - end - - -- ns_highlight/ns_filler may linger after toggling from side-by-side. - disable_refresh_and_clear_highlights(session) - - session.single_side = nil - lifecycle.update_diff_result(tabpage, nil) - - -- Retargeting can move a session between a conflicted file and an ordinary - -- one, so the merge flag follows the incoming config. - lifecycle.update_merge(tabpage, session_config.conflict) - - local original_is_virtual = is_virtual_revision(session_config.original_revision) - local modified_is_virtual = is_virtual_revision(session_config.modified_revision) - - local orig_buf = new_scratch() - local mod_buf = open_modified_for_update(modified_win, session_config, modified_is_virtual) - welcome_window.sync(modified_win) - - local should_auto_scroll = auto_scroll_to_first_hunk == true - - local render = function() - if not vim.api.nvim_win_is_valid(modified_win) then - return - end - if not vim.api.nvim_buf_is_valid(orig_buf) or not vim.api.nvim_buf_is_valid(mod_buf) then - return - end - - local lines_diff = compute_and_render_inline( - mod_buf, - orig_buf, - vim.api.nvim_buf_get_lines(orig_buf, 0, -1, false), - vim.api.nvim_buf_get_lines(mod_buf, 0, -1, false), - original_is_virtual, - modified_is_virtual, - modified_win, - should_auto_scroll - ) - if lines_diff then - commit_update(tabpage, session_config, orig_buf, mod_buf, lines_diff, saved_current_win) - end - end - - -- Each side reports itself as it lands; the last one triggers the render. - -- Sides that are already in hand are simply not awaited. - local awaited = {} - if original_is_virtual then - awaited[#awaited + 1] = "original" - end - if modified_is_virtual then - awaited[#awaited + 1] = "modified" - end - - local ready = readiness.when_all(awaited, function() - vim.schedule(render) - end) - - fill_original_for_update(orig_buf, session_config, original_is_virtual, function() - ready.done("original") - end) - - if modified_is_virtual then - fetch_into_scratch(session_config.modified_revision, session_config.git_root, session_config.modified.relative, mod_buf, function() - ready.done("modified") - end) - end - - return true -end - --- ============================================================================ --- Re-render (for auto-refresh) --- ============================================================================ - -function M.rerender(tabpage) - local session = lifecycle.get_session(tabpage) - if not session or session.layout ~= "inline" then - return - end - - local original_bufnr = session.original_bufnr - local modified_bufnr = session.modified_bufnr - - if not vim.api.nvim_buf_is_valid(original_bufnr) or not vim.api.nvim_buf_is_valid(modified_bufnr) then - return - end - - local original_lines = vim.api.nvim_buf_get_lines(original_bufnr, 0, -1, false) - local modified_lines = vim.api.nvim_buf_get_lines(modified_bufnr, 0, -1, false) - - local diff_options = { - max_computation_time_ms = config.options.diff.max_computation_time_ms, - ignore_trim_whitespace = config.options.diff.ignore_trim_whitespace, - compute_moves = config.options.diff.compute_moves, - } - - local lines_diff = diff_module.compute_diff(original_lines, modified_lines, diff_options) - if lines_diff then - inline.render_inline_diff(modified_bufnr, lines_diff, original_lines, modified_lines) - lifecycle.update_diff_result(tabpage, lines_diff) - end -end - --- ============================================================================ --- Show single file (no diff) for inline mode --- ============================================================================ - ---- Display a single file in the inline diff window without any diff decorations. ---- Used for untracked (??), added (A), and deleted (D) files in explorer/history. ----@param tabpage number ----@param file_path string Path to load (absolute for real files) ----@param opts? { revision: string?, git_root: string?, rel_path: string?, side: "original"|"modified"? } -function M.show_single_file(tabpage, file_path, opts) - opts = opts or {} - local session = lifecycle.get_session(tabpage) - if not session then - return - end - local side = opts.side or "modified" - - lifecycle.update_layout(tabpage, "inline") - local mod_win = session.modified_win - if not mod_win or not vim.api.nvim_win_is_valid(mod_win) then - return - end - - -- Clear old inline decorations - -- Disable old auto-refresh - disable_refresh_and_clear_highlights(session) - - -- Load the file - local file_bufnr - if opts.revision and opts.git_root then - -- Virtual file: reuse a buffer keyed by (git_root, revision, path) via the - -- codediff:// URL scheme. This guarantees a stable bufnr across repeated - -- calls (same fix as side_by_side.load_virtual_file). The BufReadCmd in - -- core/virtual_file.lua handles content fetching and intentionally avoids - -- setting filetype to prevent LSP attach crashes on the custom URI scheme. - local virtual_file = require("codediff.core.virtual_file") - local url = virtual_file.create_url(opts.git_root, opts.revision, opts.rel_path or file_path) - file_bufnr = vim.fn.bufadd(url) - vim.fn.bufload(file_bufnr) - vim.api.nvim_win_set_buf(mod_win, file_bufnr) - welcome_window.sync(mod_win) - else - -- Real file - file_bufnr = open_real_file(mod_win, file_path) - welcome_window.sync(mod_win) - end - - -- Update session state - local empty_buf = vim.api.nvim_create_buf(false, true) - vim.bo[empty_buf].buftype = "nofile" - - local session_path = (opts.revision and opts.rel_path) and opts.rel_path or file_path - local file_ref = path.make_ref(session_path, opts.git_root or session.git_root) - local orig_bufnr = side == "original" and file_bufnr or empty_buf - local mod_bufnr = side == "modified" and file_bufnr or empty_buf - local original = side == "original" and file_ref or path.empty() - local modified = side == "modified" and file_ref or path.empty() - local original_revision = side == "original" and opts.revision or nil - local modified_revision = side == "modified" and opts.revision or nil - - lifecycle.update_buffers(tabpage, orig_bufnr, mod_bufnr) - lifecycle.update_paths(tabpage, original, modified) - lifecycle.update_revisions(tabpage, original_revision, modified_revision) - lifecycle.update_diff_result(tabpage, { changes = {}, moves = {} }) - session.single_side = side - core.render_whole_file(file_bufnr, side) - - local view_keymaps = require("codediff.ui.view.keymaps") - view_keymaps.setup_all_keymaps(tabpage, orig_bufnr, mod_bufnr, session.panel ~= nil and session.panel.name == "explorer") - layout.arrange(tabpage) - welcome_window.sync_later(mod_win) -end - ---- Show the welcome page in the inline diff window ----@param tabpage number ----@param load_bufnr number Welcome buffer created by welcome.create_buffer -function M.show_welcome(tabpage, load_bufnr) - local session = lifecycle.get_session(tabpage) - if not session then - return - end - - lifecycle.update_layout(tabpage, "inline") - local mod_win = session.modified_win - if not mod_win or not vim.api.nvim_win_is_valid(mod_win) then - return - end - - disable_refresh_and_clear_highlights(session) - session.single_side = nil - - vim.api.nvim_win_set_buf(mod_win, load_bufnr) - welcome_window.sync(mod_win) - - local empty_buf = vim.api.nvim_create_buf(false, true) - vim.bo[empty_buf].buftype = "nofile" - - lifecycle.update_buffers(tabpage, empty_buf, load_bufnr) - lifecycle.update_paths(tabpage, path.empty(), path.empty()) - lifecycle.update_revisions(tabpage, nil, nil) - lifecycle.update_diff_result(tabpage, { changes = {}, moves = {} }) - - local view_keymaps = require("codediff.ui.view.keymaps") - view_keymaps.setup_all_keymaps(tabpage, empty_buf, load_bufnr, session.panel ~= nil and session.panel.name == "explorer") - layout.arrange(tabpage) - welcome_window.sync_later(mod_win) -end - -return M diff --git a/lua/codediff/ui/view/inline_view/buffers.lua b/lua/codediff/ui/view/inline_view/buffers.lua new file mode 100644 index 00000000..a13ed559 --- /dev/null +++ b/lua/codediff/ui/view/inline_view/buffers.lua @@ -0,0 +1,40 @@ +-- Buffer lifecycle for inline diff views. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local auto_refresh = require("codediff.ui.auto_refresh") + +function M.disable_refresh_and_clear_highlights(session) + for _, bufnr in pairs({ session.original_bufnr, session.modified_bufnr }) do + if vim.api.nvim_buf_is_valid(bufnr) then + auto_refresh.disable(bufnr) + lifecycle.clear_highlights(bufnr) + end + end +end + +--- Replace a scratch buffer's contents, restoring its read-only state. +--- Returns false when the buffer is gone, which is the caller's cue to stop: +--- the tab may have closed while the fetch was in flight. +--- @param bufnr number +--- @param lines string[] +--- @return boolean +function M.set_scratch_lines(bufnr, lines) + if not vim.api.nvim_buf_is_valid(bufnr) then + return false + end + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modifiable = false + return true +end + +--- An empty, unlisted, non-file buffer. +--- @return number +function M.new_scratch() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[bufnr].buftype = "nofile" + return bufnr +end + +return M diff --git a/lua/codediff/ui/view/inline_view/create.lua b/lua/codediff/ui/view/inline_view/create.lua new file mode 100644 index 00000000..07043c81 --- /dev/null +++ b/lua/codediff/ui/view/inline_view/create.lua @@ -0,0 +1,288 @@ +-- Creates inline diff views. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local auto_refresh = require("codediff.ui.auto_refresh") +local config = require("codediff.config") +local layout = require("codediff.ui.layout") +local welcome_window = require("codediff.ui.view.welcome_window") +local helpers = require("codediff.ui.view.helpers") +local panel = require("codediff.ui.view.panel") +local buffers = require("codediff.ui.view.inline_view.buffers") +local inline_render = require("codediff.ui.view.inline_view.render") +local inline_keymaps = require("codediff.ui.view.inline_view.keymaps") + +local is_virtual_revision = helpers.is_virtual_revision +local prepare_buffer = helpers.prepare_buffer +local is_panel_placeholder = helpers.is_panel_placeholder +local show_real_file_buffer = helpers.show_real_file_buffer +local open_real_file = helpers.open_real_file +local set_scratch_lines = buffers.set_scratch_lines +local new_scratch = buffers.new_scratch +local compute_and_render_inline = inline_render.compute_and_render_inline +local setup_keymaps = inline_keymaps.setup + +-- Helper: mark session as inline layout after creation +local function mark_inline(tabpage) + lifecycle.update_layout(tabpage, "inline") +end + +--- Options for the single inline pane. No 'list' here: side-by-side sets it +--- to keep the two panes visually identical, which does not apply to one pane. +--- @param win number +local function apply_pane_options(win) + vim.wo[win].cursorline = true + vim.wo[win].wrap = false +end + +--- Reapply-keymaps callback stored on the session. +--- @param tabpage number +--- @param original_bufnr number +--- @return function +local function make_reapply_keymaps(tabpage, original_bufnr) + return function() + local _, mb = lifecycle.get_buffers(tabpage) + if mb then + setup_keymaps(tabpage, original_bufnr, mb) + end + end +end + +--- Attach the panels, lay the tab out, announce the view, and describe it. +--- @param tabpage number +--- @param session_config SessionConfig +--- @param modified_win number +--- @param original_bufnr number +--- @param modified_bufnr number +--- @return table +local function finish_create(tabpage, session_config, modified_win, original_bufnr, modified_bufnr) + panel.setup_explorer(tabpage, session_config, modified_win, modified_win) + panel.setup_history(tabpage, session_config, modified_win, modified_win) + + layout.arrange(tabpage) + + vim.api.nvim_exec_autocmds("User", { + pattern = "CodeDiffOpen", + modeline = false, + data = { tabpage = tabpage, mode = lifecycle.event_mode(session_config.panel), layout = "inline" }, + }) + + return { modified_buf = modified_bufnr, original_buf = original_bufnr, modified_win = modified_win } +end + +--- Open the single pane with a scratch buffer, for a session whose content +--- arrives later via the panel. The hidden original side gets a scratch buffer +--- too, so the session always has two buffers to talk about. +--- @param tabpage number +--- @param modified_win number +--- @return number original_bufnr, number modified_bufnr +local function open_placeholder_pane(tabpage, modified_win) + local mod_scratch = new_scratch() + pcall(vim.api.nvim_buf_set_name, mod_scratch, "CodeDiff " .. tabpage .. ".inline") + vim.api.nvim_win_set_buf(modified_win, mod_scratch) + welcome_window.sync(modified_win) + + return new_scratch(), mod_scratch +end + +--- Show the modified side in the visible pane. +--- @param win number +--- @param info table From prepare_buffer; info.bufnr is updated in place +--- @param is_virtual boolean +local function load_visible_side(win, info, is_virtual) + if is_virtual then + if info.needs_edit then + vim.cmd("edit! " .. vim.fn.fnameescape(info.target)) + info.bufnr = vim.api.nvim_get_current_buf() + else + vim.api.nvim_win_set_buf(win, info.bufnr) + end + elseif info.needs_edit then + info.bufnr = open_real_file(win, info.target) + else + show_real_file_buffer(win, info.bufnr) + end + welcome_window.sync(win) +end + +--- Materialise the original side, which inline never puts in a window. +--- A codediff:// buffer carries bufhidden=wipe, so with no window showing it +--- :edit would destroy it at once; it gets a scratch buffer instead. +--- @param info table From prepare_buffer; info.bufnr is updated in place +--- @param is_virtual boolean +local function load_hidden_original(info, is_virtual) + if is_virtual and info.needs_edit then + info.bufnr = new_scratch() + elseif info.needs_edit then + local bufnr = vim.fn.bufadd(info.target) + vim.fn.bufload(bufnr) + info.bufnr = bufnr + end +end + +--- Call `render` once the modified buffer's virtual content has loaded. +--- @param tabpage number +--- @param modified_bufnr number +--- @param render function +local function render_after_modified_loads(tabpage, modified_bufnr, render) + local group = vim.api.nvim_create_augroup("CodeDiffInlineVirtualLoad_" .. tabpage, { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "CodeDiffVirtualFileLoaded", + callback = function(event) + if event.data and event.data.buf == modified_bufnr then + vim.schedule(render) + vim.api.nvim_del_augroup_by_id(group) + end + end, + }) +end + +--- Run `render` once both sides hold their content. +--- The original side, when virtual, is fetched here rather than through +--- BufReadCmd, because it has no window to trigger one. +--- @param ctx table { tabpage, session_config, original_info, modified_info, virtual flags } +--- @param render function +local function render_when_loaded(ctx, render) + local original_info, modified_info = ctx.original_info, ctx.modified_info + + if not ctx.original_is_virtual then + if ctx.modified_is_virtual then + render_after_modified_loads(ctx.tabpage, modified_info.bufnr, render) + else + vim.schedule(render) + end + return + end + + local git = require("codediff.core.git") + local session_config = ctx.session_config + git.get_file_content(session_config.original_revision, session_config.git_root, session_config.original.relative, function(err, lines) + vim.schedule(function() + if not set_scratch_lines(original_info.bufnr, err and {} or lines) then + return + end + + if ctx.modified_is_virtual then + render_after_modified_loads(ctx.tabpage, modified_info.bufnr, render) + else + render() + end + end) + end) +end + +---@param session_config SessionConfig +---@param filetype? string +---@param on_ready? function +---@return table|nil +function M.create(session_config, filetype, on_ready) + vim.cmd("tabnew") + local tabpage = vim.api.nvim_get_current_tabpage() + local modified_win = vim.api.nvim_get_current_win() + local initial_buf = vim.api.nvim_get_current_buf() + + --- Drop the tab's starting buffer once the real ones are in place. + local function drop_initial_buf(...) + for _, keep in ipairs({ ... }) do + if initial_buf == keep then + return + end + end + if vim.api.nvim_buf_is_valid(initial_buf) then + pcall(vim.api.nvim_buf_delete, initial_buf, { force = true }) + end + end + + if is_panel_placeholder(session_config) then + local orig_scratch, mod_scratch = open_placeholder_pane(tabpage, modified_win) + drop_initial_buf(mod_scratch) + apply_pane_options(modified_win) + + -- The panel populates this session on first file selection. + lifecycle.create_session(tabpage, session_config, { + original_bufnr = orig_scratch, + modified_bufnr = mod_scratch, + original_win = modified_win, + modified_win = modified_win, -- both point to the single window + lines_diff = {}, + reapply_keymaps = make_reapply_keymaps(tabpage, orig_scratch), + }) + + mark_inline(tabpage) + return finish_create(tabpage, session_config, modified_win, orig_scratch, mod_scratch) + end + + local original_is_virtual = is_virtual_revision(session_config.original_revision) + local modified_is_virtual = is_virtual_revision(session_config.modified_revision) + + local original_info = prepare_buffer(original_is_virtual, session_config.git_root, session_config.original_revision, session_config.original) + local modified_info = prepare_buffer(modified_is_virtual, session_config.git_root, session_config.modified_revision, session_config.modified) + + load_visible_side(modified_win, modified_info, modified_is_virtual) + load_hidden_original(original_info, original_is_virtual) + + drop_initial_buf(modified_info.bufnr, original_info.bufnr) + apply_pane_options(modified_win) + + local render = function() + if not vim.api.nvim_win_is_valid(modified_win) then + return + end + if not vim.api.nvim_buf_is_valid(original_info.bufnr) or not vim.api.nvim_buf_is_valid(modified_info.bufnr) then + return + end + + local lines_diff = compute_and_render_inline( + modified_info.bufnr, + original_info.bufnr, + vim.api.nvim_buf_get_lines(original_info.bufnr, 0, -1, false), + vim.api.nvim_buf_get_lines(modified_info.bufnr, 0, -1, false), + original_is_virtual, + modified_is_virtual, + modified_win, + config.options.diff.jump_to_first_change + ) + if not lines_diff then + return + end + + lifecycle.create_session(tabpage, session_config, { + original_bufnr = original_info.bufnr, + modified_bufnr = modified_info.bufnr, + original_win = modified_win, + modified_win = modified_win, + lines_diff = lines_diff, + reapply_keymaps = make_reapply_keymaps(tabpage, original_info.bufnr), + }) + + mark_inline(tabpage) + + auto_refresh.enable(original_info.bufnr) + auto_refresh.enable(modified_info.bufnr) + + setup_keymaps(tabpage, original_info.bufnr, modified_info.bufnr) + + -- Keep the diff pointed at the working window's file if it changes. Same + -- as the side-by-side path: the behaviour belongs to the session shape, + -- not to a layout. + require("codediff.ui.follow_working_file").enable(tabpage, original_is_virtual, modified_is_virtual) + + if on_ready then + on_ready() + end + end + + render_when_loaded({ + tabpage = tabpage, + session_config = session_config, + original_info = original_info, + modified_info = modified_info, + original_is_virtual = original_is_virtual, + modified_is_virtual = modified_is_virtual, + }, render) + + return finish_create(tabpage, session_config, modified_win, original_info.bufnr, modified_info.bufnr) +end + +return M diff --git a/lua/codediff/ui/view/inline_view/init.lua b/lua/codediff/ui/view/inline_view/init.lua new file mode 100644 index 00000000..a38bff9e --- /dev/null +++ b/lua/codediff/ui/view/inline_view/init.lua @@ -0,0 +1,15 @@ +-- Inline diff view engine. +local M = {} + +local create = require("codediff.ui.view.inline_view.create") +local update = require("codediff.ui.view.inline_view.update") +local render = require("codediff.ui.view.inline_view.render") +local single_file = require("codediff.ui.view.inline_view.single_file") + +M.create = create.create +M.update = update.update +M.rerender = render.rerender +M.show_single_file = single_file.show_single_file +M.show_welcome = single_file.show_welcome + +return M diff --git a/lua/codediff/ui/view/inline_view/keymaps.lua b/lua/codediff/ui/view/inline_view/keymaps.lua new file mode 100644 index 00000000..de2c1d5a --- /dev/null +++ b/lua/codediff/ui/view/inline_view/keymaps.lua @@ -0,0 +1,13 @@ +-- Keymap installation for inline diff sessions. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") + +function M.setup(tabpage, orig_buf, mod_buf) + local view_keymaps = require("codediff.ui.view.keymaps") + local session = lifecycle.get_session(tabpage) + local is_explorer = session and session.panel ~= nil and session.panel.name == "explorer" + view_keymaps.setup_all_keymaps(tabpage, orig_buf, mod_buf, is_explorer) +end + +return M diff --git a/lua/codediff/ui/view/inline_view/render.lua b/lua/codediff/ui/view/inline_view/render.lua new file mode 100644 index 00000000..ee802fba --- /dev/null +++ b/lua/codediff/ui/view/inline_view/render.lua @@ -0,0 +1,86 @@ +-- Diff computation and rendering for inline views. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local config = require("codediff.config") +local diff_module = require("codediff.core.diff") +local inline = require("codediff.ui.inline") +local semantic = require("codediff.ui.semantic_tokens") + +function M.compute_and_render_inline(modified_buf, original_buf, original_lines, modified_lines, original_is_virtual, modified_is_virtual, modified_win, auto_scroll_to_first_hunk) + local diff_options = { + max_computation_time_ms = config.options.diff.max_computation_time_ms, + ignore_trim_whitespace = config.options.diff.ignore_trim_whitespace, + compute_moves = config.options.diff.compute_moves, + } + + local lines_diff = diff_module.compute_diff(original_lines, modified_lines, diff_options) + if not lines_diff then + vim.notify("Failed to compute diff", vim.log.levels.ERROR) + return nil + end + + inline.render_inline_diff(modified_buf, lines_diff, original_lines, modified_lines) + + if original_is_virtual then + semantic.apply_semantic_tokens(original_buf, modified_buf) + end + if modified_is_virtual then + semantic.apply_semantic_tokens(modified_buf, original_buf) + end + + if modified_win and vim.api.nvim_win_is_valid(modified_win) then + vim.wo[modified_win].wrap = false + if auto_scroll_to_first_hunk and lines_diff.changes and #lines_diff.changes > 0 then + -- Honor session.pending_cursor_landing (cycle-hunks-across-files + -- backward direction sets it to "last"; see ui/view/navigation.lua). + -- Look up the session via the window's tabpage because this code can + -- run from a scheduled callback on a different tab. + local lifecycle = require("codediff.ui.lifecycle") + local tabpage = vim.api.nvim_win_get_tabpage(modified_win) + local session = tabpage and lifecycle.get_session(tabpage) or nil + local landing = session and session.pending_cursor_landing + if session then + session.pending_cursor_landing = nil + end + + local target_line = landing == "last" and lines_diff.changes[#lines_diff.changes].modified.start_line or lines_diff.changes[1].modified.start_line + pcall(vim.api.nvim_win_set_cursor, modified_win, { target_line, 0 }) + vim.api.nvim_set_current_win(modified_win) + vim.cmd("normal! zz") + end + end + + return lines_diff +end + +function M.rerender(tabpage) + local session = lifecycle.get_session(tabpage) + if not session or session.layout ~= "inline" then + return + end + + local original_bufnr = session.original_bufnr + local modified_bufnr = session.modified_bufnr + + if not vim.api.nvim_buf_is_valid(original_bufnr) or not vim.api.nvim_buf_is_valid(modified_bufnr) then + return + end + + local original_lines = vim.api.nvim_buf_get_lines(original_bufnr, 0, -1, false) + local modified_lines = vim.api.nvim_buf_get_lines(modified_bufnr, 0, -1, false) + + local diff_options = { + max_computation_time_ms = config.options.diff.max_computation_time_ms, + ignore_trim_whitespace = config.options.diff.ignore_trim_whitespace, + compute_moves = config.options.diff.compute_moves, + } + + local lines_diff = diff_module.compute_diff(original_lines, modified_lines, diff_options) + if lines_diff then + inline.render_inline_diff(modified_bufnr, lines_diff, original_lines, modified_lines) + lifecycle.update_diff_result(tabpage, lines_diff) + end +end + +return M diff --git a/lua/codediff/ui/view/inline_view/single_file.lua b/lua/codediff/ui/view/inline_view/single_file.lua new file mode 100644 index 00000000..20d3130a --- /dev/null +++ b/lua/codediff/ui/view/inline_view/single_file.lua @@ -0,0 +1,119 @@ +-- Displays one-sided files and welcome content in inline views. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local core = require("codediff.ui.core") +local path = require("codediff.core.path") +local layout = require("codediff.ui.layout") +local welcome_window = require("codediff.ui.view.welcome_window") +local helpers = require("codediff.ui.view.helpers") +local buffers = require("codediff.ui.view.inline_view.buffers") + +local open_real_file = helpers.open_real_file +local disable_refresh_and_clear_highlights = buffers.disable_refresh_and_clear_highlights + +--- Display a single file in the inline diff window without any diff decorations. +--- Used for untracked (??), added (A), and deleted (D) files in explorer/history. +---@param tabpage number +---@param file_path string Path to load (absolute for real files) +---@param opts? { revision: string?, git_root: string?, rel_path: string?, side: "original"|"modified"? } +function M.show_single_file(tabpage, file_path, opts) + opts = opts or {} + local session = lifecycle.get_session(tabpage) + if not session then + return + end + local side = opts.side or "modified" + + lifecycle.update_layout(tabpage, "inline") + local mod_win = session.modified_win + if not mod_win or not vim.api.nvim_win_is_valid(mod_win) then + return + end + + -- Clear old inline decorations + -- Disable old auto-refresh + disable_refresh_and_clear_highlights(session) + + -- Load the file + local file_bufnr + if opts.revision and opts.git_root then + -- Virtual file: reuse a buffer keyed by (git_root, revision, path) via the + -- codediff:// URL scheme. This guarantees a stable bufnr across repeated + -- calls (same fix as side_by_side.load_virtual_file). The BufReadCmd in + -- core/virtual_file.lua handles content fetching and intentionally avoids + -- setting filetype to prevent LSP attach crashes on the custom URI scheme. + local virtual_file = require("codediff.core.virtual_file") + local url = virtual_file.create_url(opts.git_root, opts.revision, opts.rel_path or file_path) + file_bufnr = vim.fn.bufadd(url) + vim.fn.bufload(file_bufnr) + vim.api.nvim_win_set_buf(mod_win, file_bufnr) + welcome_window.sync(mod_win) + else + -- Real file + file_bufnr = open_real_file(mod_win, file_path) + welcome_window.sync(mod_win) + end + + -- Update session state + local empty_buf = vim.api.nvim_create_buf(false, true) + vim.bo[empty_buf].buftype = "nofile" + + local session_path = (opts.revision and opts.rel_path) and opts.rel_path or file_path + local file_ref = path.make_ref(session_path, opts.git_root or session.git_root) + local orig_bufnr = side == "original" and file_bufnr or empty_buf + local mod_bufnr = side == "modified" and file_bufnr or empty_buf + local original = side == "original" and file_ref or path.empty() + local modified = side == "modified" and file_ref or path.empty() + local original_revision = side == "original" and opts.revision or nil + local modified_revision = side == "modified" and opts.revision or nil + + lifecycle.update_buffers(tabpage, orig_bufnr, mod_bufnr) + lifecycle.update_paths(tabpage, original, modified) + lifecycle.update_revisions(tabpage, original_revision, modified_revision) + lifecycle.update_diff_result(tabpage, { changes = {}, moves = {} }) + session.single_side = side + core.render_whole_file(file_bufnr, side) + + local view_keymaps = require("codediff.ui.view.keymaps") + view_keymaps.setup_all_keymaps(tabpage, orig_bufnr, mod_bufnr, session.panel ~= nil and session.panel.name == "explorer") + layout.arrange(tabpage) + welcome_window.sync_later(mod_win) +end + +--- Show the welcome page in the inline diff window +---@param tabpage number +---@param load_bufnr number Welcome buffer created by welcome.create_buffer +function M.show_welcome(tabpage, load_bufnr) + local session = lifecycle.get_session(tabpage) + if not session then + return + end + + lifecycle.update_layout(tabpage, "inline") + local mod_win = session.modified_win + if not mod_win or not vim.api.nvim_win_is_valid(mod_win) then + return + end + + disable_refresh_and_clear_highlights(session) + session.single_side = nil + + vim.api.nvim_win_set_buf(mod_win, load_bufnr) + welcome_window.sync(mod_win) + + local empty_buf = vim.api.nvim_create_buf(false, true) + vim.bo[empty_buf].buftype = "nofile" + + lifecycle.update_buffers(tabpage, empty_buf, load_bufnr) + lifecycle.update_paths(tabpage, path.empty(), path.empty()) + lifecycle.update_revisions(tabpage, nil, nil) + lifecycle.update_diff_result(tabpage, { changes = {}, moves = {} }) + + local view_keymaps = require("codediff.ui.view.keymaps") + view_keymaps.setup_all_keymaps(tabpage, empty_buf, load_bufnr, session.panel ~= nil and session.panel.name == "explorer") + layout.arrange(tabpage) + welcome_window.sync_later(mod_win) +end + +return M diff --git a/lua/codediff/ui/view/inline_view/update.lua b/lua/codediff/ui/view/inline_view/update.lua new file mode 100644 index 00000000..a2c0c6da --- /dev/null +++ b/lua/codediff/ui/view/inline_view/update.lua @@ -0,0 +1,204 @@ +-- Updates inline views for Explorer and History file switches. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local auto_refresh = require("codediff.ui.auto_refresh") +local layout = require("codediff.ui.layout") +local welcome_window = require("codediff.ui.view.welcome_window") +local helpers = require("codediff.ui.view.helpers") +local readiness = require("codediff.ui.view.readiness") +local buffers = require("codediff.ui.view.inline_view.buffers") +local inline_render = require("codediff.ui.view.inline_view.render") +local inline_keymaps = require("codediff.ui.view.inline_view.keymaps") + +local is_virtual_revision = helpers.is_virtual_revision +local prepare_buffer = helpers.prepare_buffer +local show_real_file_buffer = helpers.show_real_file_buffer +local open_real_file = helpers.open_real_file +local disable_refresh_and_clear_highlights = buffers.disable_refresh_and_clear_highlights +local set_scratch_lines = buffers.set_scratch_lines +local new_scratch = buffers.new_scratch +local compute_and_render_inline = inline_render.compute_and_render_inline +local setup_keymaps = inline_keymaps.setup + +--- Fetch a revision from git into a scratch buffer, then signal completion. +--- Signals nothing if the buffer died while the fetch was in flight. +--- @param revision string +--- @param git_root string +--- @param relative string +--- @param bufnr number +--- @param done function +local function fetch_into_scratch(revision, git_root, relative, bufnr, done) + require("codediff.core.git").get_file_content(revision, git_root, relative, function(err, lines) + vim.schedule(function() + if set_scratch_lines(bufnr, err and {} or lines) then + done() + end + end) + end) +end + +--- Put the modified side in the pane. +--- Unlike create, a virtual revision goes into a scratch buffer rather than a +--- codediff:// URI, so retargeting never races a pending BufReadCmd. +--- @param win number +--- @param session_config SessionConfig +--- @param is_virtual boolean +--- @return number bufnr +local function open_modified_for_update(win, session_config, is_virtual) + if is_virtual then + local mod_buf = new_scratch() + vim.bo[mod_buf].modifiable = true + vim.api.nvim_win_set_buf(win, mod_buf) + local ft = vim.filetype.match({ filename = session_config.modified.absolute }) + if ft then + vim.bo[mod_buf].filetype = ft + end + return mod_buf + end + + local info = prepare_buffer(false, session_config.git_root, nil, session_config.modified) + if info.needs_edit then + return open_real_file(win, info.target) + end + show_real_file_buffer(win, info.bufnr) + return info.bufnr +end + +--- Fill the hidden original side. A real file is copied in synchronously; a +--- revision is fetched and lands through `done`. +--- @param orig_buf number +--- @param session_config SessionConfig +--- @param is_virtual boolean +--- @param done function Called when an async fetch lands +local function fill_original_for_update(orig_buf, session_config, is_virtual, done) + if is_virtual then + -- Retargeting can leave the original path empty (a file added in the + -- modified revision), so fall back to the modified side's path. + local relative = (session_config.original.relative ~= "" and session_config.original.relative) or session_config.modified.relative + fetch_into_scratch(session_config.original_revision, session_config.git_root, relative, orig_buf, done) + return + end + + local orig_path = (session_config.original.absolute ~= "" and session_config.original.absolute) or session_config.modified.absolute + if orig_path and orig_path ~= "" then + local real_bufnr = vim.fn.bufadd(orig_path) + vim.fn.bufload(real_bufnr) + set_scratch_lines(orig_buf, vim.api.nvim_buf_get_lines(real_bufnr, 0, -1, false)) + end +end + +--- Point the session at the newly computed diff and re-arm everything hanging +--- off it: refresh, keymaps, layout, and the window the user was in. +--- @param tabpage number +--- @param session_config SessionConfig +--- @param orig_buf number +--- @param mod_buf number +--- @param lines_diff table +--- @param saved_current_win number? +local function commit_update(tabpage, session_config, orig_buf, mod_buf, lines_diff, saved_current_win) + lifecycle.update_buffers(tabpage, orig_buf, mod_buf) + lifecycle.update_git_root(tabpage, session_config.git_root) + lifecycle.update_revisions(tabpage, session_config.original_revision, session_config.modified_revision) + lifecycle.update_diff_result(tabpage, lines_diff) + lifecycle.update_changedtick(tabpage, vim.api.nvim_buf_get_changedtick(orig_buf), vim.api.nvim_buf_get_changedtick(mod_buf)) + lifecycle.update_paths(tabpage, session_config.original, session_config.modified) + + auto_refresh.enable(orig_buf) + auto_refresh.enable(mod_buf) + + setup_keymaps(tabpage, orig_buf, mod_buf) + layout.arrange(tabpage) + + if saved_current_win and vim.api.nvim_win_is_valid(saved_current_win) then + vim.api.nvim_set_current_win(saved_current_win) + end +end + +---@param tabpage number +---@param session_config SessionConfig +---@param auto_scroll_to_first_hunk boolean? +---@return boolean +function M.update(tabpage, session_config, auto_scroll_to_first_hunk) + local saved_current_win = vim.api.nvim_get_current_win() + + local session = lifecycle.get_session(tabpage) + if not session then + return false + end + + local modified_win = session.modified_win + if not modified_win or not vim.api.nvim_win_is_valid(modified_win) then + return false + end + + -- ns_highlight/ns_filler may linger after toggling from side-by-side. + disable_refresh_and_clear_highlights(session) + + session.single_side = nil + lifecycle.update_diff_result(tabpage, nil) + + -- Retargeting can move a session between a conflicted file and an ordinary + -- one, so the merge flag follows the incoming config. + lifecycle.update_merge(tabpage, session_config.conflict) + + local original_is_virtual = is_virtual_revision(session_config.original_revision) + local modified_is_virtual = is_virtual_revision(session_config.modified_revision) + + local orig_buf = new_scratch() + local mod_buf = open_modified_for_update(modified_win, session_config, modified_is_virtual) + welcome_window.sync(modified_win) + + local should_auto_scroll = auto_scroll_to_first_hunk == true + + local render = function() + if not vim.api.nvim_win_is_valid(modified_win) then + return + end + if not vim.api.nvim_buf_is_valid(orig_buf) or not vim.api.nvim_buf_is_valid(mod_buf) then + return + end + + local lines_diff = compute_and_render_inline( + mod_buf, + orig_buf, + vim.api.nvim_buf_get_lines(orig_buf, 0, -1, false), + vim.api.nvim_buf_get_lines(mod_buf, 0, -1, false), + original_is_virtual, + modified_is_virtual, + modified_win, + should_auto_scroll + ) + if lines_diff then + commit_update(tabpage, session_config, orig_buf, mod_buf, lines_diff, saved_current_win) + end + end + + -- Each side reports itself as it lands; the last one triggers the render. + -- Sides that are already in hand are simply not awaited. + local awaited = {} + if original_is_virtual then + awaited[#awaited + 1] = "original" + end + if modified_is_virtual then + awaited[#awaited + 1] = "modified" + end + + local ready = readiness.when_all(awaited, function() + vim.schedule(render) + end) + + fill_original_for_update(orig_buf, session_config, original_is_virtual, function() + ready.done("original") + end) + + if modified_is_virtual then + fetch_into_scratch(session_config.modified_revision, session_config.git_root, session_config.modified.relative, mod_buf, function() + ready.done("modified") + end) + end + + return true +end + +return M diff --git a/lua/codediff/ui/view/render.lua b/lua/codediff/ui/view/render.lua index 89126670..90da4475 100644 --- a/lua/codediff/ui/view/render.lua +++ b/lua/codediff/ui/view/render.lua @@ -156,92 +156,6 @@ function M.compute_and_render( return lines_diff end --- Conflict mode rendering: Both buffers show diff against base with alignment --- Left buffer (:3: theirs/incoming) and Right buffer (:2: ours/current) --- Both show green highlights indicating changes from base (:1:) --- Filler lines are inserted to align corresponding changes --- @param original_buf number: Left buffer (incoming :3:) --- @param modified_buf number: Right buffer (current :2:) --- @param base_lines table: Base content (:1:) --- @param original_lines table: Incoming content (:3:) --- @param modified_lines table: Current content (:2:) --- @param original_win number: Left window --- @param modified_win number: Right window --- @param auto_scroll_to_first_hunk boolean: Whether to scroll to first change --- @return table: { base_to_original_diff, base_to_modified_diff } -function M.compute_and_render_conflict(original_buf, modified_buf, base_lines, original_lines, modified_lines, original_win, modified_win, auto_scroll_to_first_hunk) - local diff_options = { - max_computation_time_ms = config.options.diff.max_computation_time_ms, - ignore_trim_whitespace = config.options.diff.ignore_trim_whitespace, - compute_moves = config.options.diff.compute_moves, - } - - -- Compute base -> original (incoming) diff - local base_to_original_diff = diff_module.compute_diff(base_lines, original_lines, diff_options) - if not base_to_original_diff then - vim.notify("Failed to compute base->incoming diff", vim.log.levels.ERROR) - return nil - end - - -- Compute base -> modified (current) diff - local base_to_modified_diff = diff_module.compute_diff(base_lines, modified_lines, diff_options) - if not base_to_modified_diff then - vim.notify("Failed to compute base->current diff", vim.log.levels.ERROR) - return nil - end - - -- Render merge view with alignment and filler lines - local render_result = core.render_merge_view(original_buf, modified_buf, base_to_original_diff, base_to_modified_diff, base_lines, original_lines, modified_lines) - - -- Apply semantic tokens (both are virtual buffers in conflict mode) - semantic.apply_semantic_tokens(original_buf, modified_buf) - semantic.apply_semantic_tokens(modified_buf, original_buf) - - -- Setup window options with structural scroll-sync (filler lines enable proper alignment) - if original_win and modified_win and vim.api.nvim_win_is_valid(original_win) and vim.api.nvim_win_is_valid(modified_win) then - vim.wo[original_win].wrap = false - vim.wo[modified_win].wrap = false - - -- Reset scroll position and bind the two panes (the result pane, if any, - -- is added to the group later by conflict_window once it exists). - vim.api.nvim_win_set_cursor(original_win, { 1, 0 }) - vim.api.nvim_win_set_cursor(modified_win, { 1, 0 }) - local scroll = require("codediff.ui.scroll") - local tabpage = vim.api.nvim_win_get_tabpage(modified_win) - scroll.bind(tabpage, { original_win, modified_win }) - scroll.resync(tabpage, modified_win) - - -- Scroll to first change in either buffer - if auto_scroll_to_first_hunk then - local first_line = nil - if #base_to_original_diff.changes > 0 then - first_line = base_to_original_diff.changes[1].modified.start_line - elseif #base_to_modified_diff.changes > 0 then - first_line = base_to_modified_diff.changes[1].modified.start_line - end - - if first_line then - pcall(vim.api.nvim_win_set_cursor, original_win, { first_line, 0 }) - pcall(vim.api.nvim_win_set_cursor, modified_win, { first_line, 0 }) - if vim.api.nvim_win_is_valid(modified_win) then - vim.api.nvim_set_current_win(modified_win) - vim.cmd("normal! zz") - end - end - end - end - - return { - base_to_original_diff = base_to_original_diff, - base_to_modified_diff = base_to_modified_diff, - conflict_blocks = render_result and render_result.conflict_blocks or {}, - -- Pass through the per-side content so callers (e.g. conflict_window's - -- auto-merge seed) can compute Result without re-fetching buffers. - original_lines = original_lines, - modified_lines = modified_lines, - } -end - -- Common logic: Setup auto-refresh for all diff buffers (real and virtual) function M.setup_auto_refresh(original_buf, modified_buf, original_is_virtual, modified_is_virtual) local auto_refresh = require("codediff.ui.auto_refresh") diff --git a/lua/codediff/ui/view/side_by_side.lua b/lua/codediff/ui/view/side_by_side.lua deleted file mode 100644 index 6b8c4672..00000000 --- a/lua/codediff/ui/view/side_by_side.lua +++ /dev/null @@ -1,911 +0,0 @@ --- Side-by-side diff view engine --- Handles creation and updating of two-window diff views -local M = {} - -local lifecycle = require("codediff.ui.lifecycle") -local virtual_file = require("codediff.core.virtual_file") -local auto_refresh = require("codediff.ui.auto_refresh") -local config = require("codediff.config") -local core = require("codediff.ui.core") -local path = require("codediff.core.path") - --- Eagerly load explorer and history to avoid lazy require failures --- when CWD changes in vim.schedule callbacks -local explorer_module = require("codediff.ui.explorer") -local history_module = require("codediff.ui.history") -local layout = require("codediff.ui.layout") - -local helpers = require("codediff.ui.view.helpers") -local readiness = require("codediff.ui.view.readiness") -local render = require("codediff.ui.view.render") -local view_keymaps = require("codediff.ui.view.keymaps") -local conflict_window = require("codediff.ui.view.conflict_window") -local panel = require("codediff.ui.view.panel") -local welcome_window = require("codediff.ui.view.welcome_window") - -local is_virtual_revision = helpers.is_virtual_revision -local prepare_buffer = helpers.prepare_buffer -local is_panel_placeholder = helpers.is_panel_placeholder -local show_real_file_buffer = helpers.show_real_file_buffer -local open_real_file = helpers.open_real_file -local compute_and_render = render.compute_and_render -local compute_and_render_conflict = render.compute_and_render_conflict -local setup_auto_refresh = render.setup_auto_refresh -local setup_conflict_result_window = conflict_window.setup_conflict_result_window -local setup_all_keymaps = view_keymaps.setup_all_keymaps - --- ============================================================================ --- Create --- ============================================================================ - ---- Split direction that lands the modified pane on the side the user asked for. ---- Explicit rather than relying on 'splitright'. ---- @return string -local function diff_split_cmd() - return config.options.diff.original_position == "right" and "leftabove vsplit" or "rightbelow vsplit" -end - ---- Open the two panes with throwaway scratch buffers, for a session whose ---- content arrives later via the panel. ---- @param tabpage number ---- @return number original_win, number modified_win, table original_info, table modified_info -local function open_placeholder_panes(tabpage) - local original_win = vim.api.nvim_get_current_win() - vim.cmd(diff_split_cmd()) - local modified_win = vim.api.nvim_get_current_win() - - -- A buffer each, so the tab's initial buffer can be deleted afterwards. - local orig_scratch = vim.api.nvim_create_buf(false, true) - local mod_scratch = vim.api.nvim_create_buf(false, true) - vim.bo[orig_scratch].buftype = "nofile" - vim.bo[mod_scratch].buftype = "nofile" - pcall(vim.api.nvim_buf_set_name, orig_scratch, "CodeDiff " .. tabpage .. ".1") - pcall(vim.api.nvim_buf_set_name, mod_scratch, "CodeDiff " .. tabpage .. ".2") - vim.api.nvim_win_set_buf(original_win, orig_scratch) - vim.api.nvim_win_set_buf(modified_win, mod_scratch) - welcome_window.sync(original_win) - welcome_window.sync(modified_win) - - return original_win, modified_win, { bufnr = orig_scratch }, { bufnr = mod_scratch } -end - ---- Show one side's content in `win`, whether it is a git revision or a real file. ---- @param win number ---- @param info table From prepare_buffer ---- @param is_virtual boolean -local function load_side(win, info, is_virtual) - if is_virtual then - if info.needs_edit then - vim.cmd("edit! " .. vim.fn.fnameescape(info.target)) - info.bufnr = vim.api.nvim_get_current_buf() - else - vim.api.nvim_win_set_buf(win, info.bufnr) - end - elseif info.needs_edit then - info.bufnr = open_real_file(win, info.target) - else - show_real_file_buffer(win, info.bufnr) - end -end - ---- Open the two panes with the diff's actual content loaded. ---- @param session_config SessionConfig ---- @return number original_win, number modified_win, table original_info, table modified_info -local function open_diff_panes(session_config) - local original_is_virtual = is_virtual_revision(session_config.original_revision) - local modified_is_virtual = is_virtual_revision(session_config.modified_revision) - - local original_info = prepare_buffer(original_is_virtual, session_config.git_root, session_config.original_revision, session_config.original) - local modified_info = prepare_buffer(modified_is_virtual, session_config.git_root, session_config.modified_revision, session_config.modified) - - local original_win = vim.api.nvim_get_current_win() - load_side(original_win, original_info, original_is_virtual) - - vim.cmd(diff_split_cmd()) - local modified_win = vim.api.nvim_get_current_win() - load_side(modified_win, modified_info, modified_is_virtual) - - welcome_window.sync(original_win) - welcome_window.sync(modified_win) - - return original_win, modified_win, original_info, modified_info -end - ---- Window options both diff panes get. 'wrap' is load-bearing: the scroll-sync ---- maps one buffer line to one screen row. 'number'/'relativenumber' are left ---- alone so the user's own settings survive. ---- @param original_win number ---- @param modified_win number -local function apply_pane_options(original_win, modified_win) - local win_opts = { - cursorline = true, - wrap = false, - list = false, - } - for opt, val in pairs(win_opts) do - vim.wo[original_win][opt] = val - vim.wo[modified_win][opt] = val - end -end - ---- Reapply-keymaps callback stored on the session, so a shape change (panel ---- appearing, layout toggle) can reinstall the right mappings. ---- @param tabpage number ---- @param opts? table { conflict: boolean } ---- @return function -local function make_reapply_keymaps(tabpage, opts) - local is_conflict = opts and opts.conflict or false - return function() - local ob, mb = lifecycle.get_buffers(tabpage) - if not ob or not mb then - return - end - if is_conflict then - setup_all_keymaps(tabpage, ob, mb, false) - require("codediff.ui.conflict").setup_keymaps(tabpage) - else - setup_all_keymaps(tabpage, ob, mb, lifecycle.get_panel_name(tabpage) == "explorer") - end - end -end - ---- Attach the panels, announce the view, and describe it to the caller. ---- @param tabpage number ---- @param session_config SessionConfig ---- @param original_win number ---- @param modified_win number ---- @param original_info table ---- @param modified_info table ---- @return table -local function finish_create(tabpage, session_config, original_win, modified_win, original_info, modified_info) - panel.setup_explorer(tabpage, session_config, original_win, modified_win) - panel.setup_history(tabpage, session_config, original_win, modified_win) - - vim.api.nvim_exec_autocmds("User", { - pattern = "CodeDiffOpen", - modeline = false, - data = { - tabpage = tabpage, - mode = lifecycle.event_mode(session_config.panel), - }, - }) - - return { - original_buf = original_info.bufnr, - modified_buf = modified_info.bufnr, - original_win = original_win, - modified_win = modified_win, - } -end - ---- Render a 3-way merge: fetch the merge base, diff both sides against it, ---- then register the session and open the result pane. ---- @param ctx table { tabpage, session_config, wins, infos, lines, on_ready } -local function render_conflict_view(ctx) - local git = require("codediff.core.git") - local session_config = ctx.session_config - local tabpage = ctx.tabpage - local original_win, modified_win = ctx.original_win, ctx.modified_win - local original_info, modified_info = ctx.original_info, ctx.modified_info - - git.get_file_content(":1", session_config.git_root, session_config.original.relative, function(err, base_lines) - -- Add/add conflicts (AA) have no base version; treat it as empty. - if err then - base_lines = {} - end - - vim.schedule(function() - local conflict_diffs = compute_and_render_conflict( - original_info.bufnr, - modified_info.bufnr, - base_lines, - ctx.original_lines, - ctx.modified_lines, - original_win, - modified_win, - config.options.diff.jump_to_first_change - ) - if not conflict_diffs then - return - end - - lifecycle.create_session(tabpage, session_config, { - original_bufnr = original_info.bufnr, - modified_bufnr = modified_info.bufnr, - original_win = original_win, - modified_win = modified_win, - lines_diff = conflict_diffs.base_to_modified_diff, - reapply_keymaps = make_reapply_keymaps(tabpage, { conflict = true }), - }) - - local success = setup_conflict_result_window(tabpage, session_config, original_win, modified_win, base_lines, conflict_diffs, false) - if success then - setup_all_keymaps(tabpage, original_info.bufnr, modified_info.bufnr, false) - -- After setup_all_keymaps, so the conflict mappings win. - require("codediff.ui.conflict").setup_keymaps(tabpage) - end - - if ctx.on_ready then - ctx.on_ready() - end - end) - end) -end - ---- Render an ordinary two-pane diff and register the session. ---- @param ctx table { tabpage, session_config, wins, infos, lines, virtual flags, on_ready } -local function render_diff_view(ctx) - local session_config = ctx.session_config - local tabpage = ctx.tabpage - local original_info, modified_info = ctx.original_info, ctx.modified_info - - local lines_diff = compute_and_render( - original_info.bufnr, - modified_info.bufnr, - ctx.original_lines, - ctx.modified_lines, - ctx.original_is_virtual, - ctx.modified_is_virtual, - ctx.original_win, - ctx.modified_win, - config.options.diff.jump_to_first_change - ) - if not lines_diff then - return - end - - lifecycle.create_session(tabpage, session_config, { - original_bufnr = original_info.bufnr, - modified_bufnr = modified_info.bufnr, - original_win = ctx.original_win, - modified_win = ctx.modified_win, - lines_diff = lines_diff, - reapply_keymaps = make_reapply_keymaps(tabpage), - }) - - -- Real file buffers only; virtual ones never change under us. - setup_auto_refresh(original_info.bufnr, modified_info.bufnr, ctx.original_is_virtual, ctx.modified_is_virtual) - setup_all_keymaps(tabpage, original_info.bufnr, modified_info.bufnr, false) - require("codediff.ui.follow_working_file").enable(tabpage, ctx.original_is_virtual, ctx.modified_is_virtual) - - if ctx.on_ready then - ctx.on_ready() - end -end - ---- Run `render` once both panes hold their final content. Virtual buffers ---- load asynchronously via BufReadCmd; real files only need the pending :edit. ---- @param tabpage number ---- @param original_info table ---- @param modified_info table ---- @param original_is_virtual boolean ---- @param modified_is_virtual boolean ---- @param render function -local function render_when_loaded(tabpage, original_info, modified_info, original_is_virtual, modified_is_virtual, render) - local awaited = {} - if original_is_virtual then - awaited[#awaited + 1] = "original" - end - if modified_is_virtual then - awaited[#awaited + 1] = "modified" - end - if #awaited == 0 then - vim.schedule(render) - return - end - - local group = vim.api.nvim_create_augroup("CodeDiffVirtualFileHighlight_" .. tabpage, { clear = true }) - local ready = readiness.when_all(awaited, function() - vim.schedule(render) - vim.api.nvim_del_augroup_by_id(group) - end) - - vim.api.nvim_create_autocmd("User", { - group = group, - pattern = "CodeDiffVirtualFileLoaded", - callback = function(event) - local buf = event.data and event.data.buf - if not buf then - return - end - if original_is_virtual and buf == original_info.bufnr then - ready.done("original") - end - if modified_is_virtual and buf == modified_info.bufnr then - ready.done("modified") - end - end, - }) -end - ----@param session_config SessionConfig ----@param filetype? string ----@param on_ready? function ----@return table|nil -function M.create(session_config, filetype, on_ready) - vim.cmd("tabnew") - local tabpage = vim.api.nvim_get_current_tabpage() - local initial_buf = vim.api.nvim_get_current_buf() - - local placeholder = is_panel_placeholder(session_config) - local original_win, modified_win, original_info, modified_info - - if placeholder then - original_win, modified_win, original_info, modified_info = open_placeholder_panes(tabpage) - else - original_win, modified_win, original_info, modified_info = open_diff_panes(session_config) - end - - -- Clean up initial buffer - if vim.api.nvim_buf_is_valid(initial_buf) and initial_buf ~= original_info.bufnr and initial_buf ~= modified_info.bufnr then - pcall(vim.api.nvim_buf_delete, initial_buf, { force = true }) - end - - apply_pane_options(original_win, modified_win) - - if placeholder then - -- The panel populates this session on first file selection. - lifecycle.create_session(tabpage, session_config, { - original_bufnr = original_info.bufnr, - modified_bufnr = modified_info.bufnr, - original_win = original_win, - modified_win = modified_win, - lines_diff = {}, -- Empty diff result - will be updated on first file selection - reapply_keymaps = make_reapply_keymaps(tabpage), - }) - else - local original_is_virtual = is_virtual_revision(session_config.original_revision) - local modified_is_virtual = is_virtual_revision(session_config.modified_revision) - - local render = function() - -- The panes may have been closed, or the buffers wiped, while we waited. - if not vim.api.nvim_win_is_valid(original_win) or not vim.api.nvim_win_is_valid(modified_win) then - return - end - if not vim.api.nvim_buf_is_valid(original_info.bufnr) or not vim.api.nvim_buf_is_valid(modified_info.bufnr) then - return - end - - -- Called from vim.schedule, possibly with another tab current. syncbind - -- and friends act on the current tab, so switch to ours first. - local target_tab = vim.api.nvim_win_get_tabpage(modified_win) - if vim.api.nvim_get_current_tabpage() ~= target_tab then - vim.api.nvim_set_current_tabpage(target_tab) - end - - -- Read from the buffers, the single source of truth. - local ctx = { - tabpage = tabpage, - session_config = session_config, - original_win = original_win, - modified_win = modified_win, - original_info = original_info, - modified_info = modified_info, - original_lines = vim.api.nvim_buf_get_lines(original_info.bufnr, 0, -1, false), - modified_lines = vim.api.nvim_buf_get_lines(modified_info.bufnr, 0, -1, false), - original_is_virtual = original_is_virtual, - modified_is_virtual = modified_is_virtual, - on_ready = on_ready, - } - - if session_config.conflict then - render_conflict_view(ctx) - else - render_diff_view(ctx) - end - end - - render_when_loaded(tabpage, original_info, modified_info, original_is_virtual, modified_is_virtual, render) - end - - return finish_create(tabpage, session_config, original_win, modified_win, original_info, modified_info) -end - --- ============================================================================ --- Update --- ============================================================================ - ---- Put one side's content into `win` during an update, reusing the buffer when ---- it is still alive. Unlike load_side, this has to cope with the buffer having ---- been wiped since the session was built. ---- @param win number ---- @param info table From prepare_buffer; info.bufnr is updated in place ---- @param is_virtual boolean -local function reload_side(win, info, is_virtual) - if not vim.api.nvim_win_is_valid(win) then - return - end - - local function edit_in_place() - vim.api.nvim_set_current_win(win) - vim.cmd("edit! " .. vim.fn.fnameescape(info.target)) - info.bufnr = vim.api.nvim_get_current_buf() - end - - if info.needs_edit then - if not is_virtual then - info.bufnr = open_real_file(win, info.target) - elseif info.bufnr and vim.api.nvim_buf_is_valid(info.bufnr) then - vim.api.nvim_win_set_buf(win, info.bufnr) - virtual_file.refresh_buffer(info.bufnr) - else - edit_in_place() - end - return - end - - if vim.api.nvim_buf_is_valid(info.bufnr) then - if is_virtual then - vim.api.nvim_win_set_buf(win, info.bufnr) - else - show_real_file_buffer(win, info.bufnr) - end - elseif is_virtual then - edit_in_place() - else - info.bufnr = open_real_file(win, info.target) - end -end - ---- Run `render` once every side that needs loading has loaded. ---- @param tabpage number ---- @param original_info table ---- @param modified_info table ---- @param wait_state table { original: boolean, modified: boolean } ---- @param render function -local function render_when_reloaded(tabpage, original_info, modified_info, wait_state, render) - local awaited = {} - if wait_state.original then - awaited[#awaited + 1] = "original" - end - if wait_state.modified then - awaited[#awaited + 1] = "modified" - end - if #awaited == 0 then - return - end - - local group = vim.api.nvim_create_augroup("CodeDiffVirtualFileUpdate_" .. tabpage, { clear = true }) - local ready = readiness.when_all(awaited, function() - vim.schedule(render) - vim.api.nvim_del_augroup_by_id(group) - end) - - vim.api.nvim_create_autocmd("User", { - group = group, - pattern = "CodeDiffVirtualFileLoaded", - callback = function(event) - local buf = event.data and event.data.buf - if not buf then - return - end - if buf == original_info.bufnr then - ready.done("original") - end - if buf == modified_info.bufnr then - ready.done("modified") - end - end, - }) -end - ----@param tabpage number ----@param session_config SessionConfig ----@param auto_scroll_to_first_hunk boolean? ----@return boolean -function M.update(tabpage, session_config, auto_scroll_to_first_hunk) - -- Save current window to restore focus after update - local saved_current_win = vim.api.nvim_get_current_win() - - -- Get existing session - local session = lifecycle.get_session(tabpage) - if not session then - return false - end - session.single_side = nil - - -- Get existing buffers and windows - local old_original_buf, old_modified_buf = lifecycle.get_buffers(tabpage) - local original_win, modified_win = lifecycle.get_windows(tabpage) - - if not old_original_buf or not old_modified_buf then - return false - end - if not original_win and not modified_win then - return false - end - - -- Disable auto-refresh temporarily - auto_refresh.disable(old_original_buf) - auto_refresh.disable(old_modified_buf) - - -- Clear highlights from old buffers (before they're replaced/deleted) - lifecycle.clear_highlights(old_original_buf) - lifecycle.clear_highlights(old_modified_buf) - - -- Clear stored_diff_result to signal that an update is in progress - lifecycle.update_diff_result(tabpage, nil) - - -- Retargeting can move a session between a conflicted file and an ordinary - -- one, so the merge flag follows the incoming config. - lifecycle.update_merge(tabpage, session_config.conflict) - - -- Handle result window when switching between conflict and non-conflict modes - local old_result_bufnr, old_result_win = lifecycle.get_result(tabpage) - if not session_config.conflict and old_result_win and vim.api.nvim_win_is_valid(old_result_win) then - vim.api.nvim_win_close(old_result_win, false) - lifecycle.set_result(tabpage, nil, nil) - end - - -- Restore second window if returning from single-pane mode - if session.single_pane then - local split_cmd = config.options.diff.original_position == "right" and "leftabove vsplit" or "rightbelow vsplit" - - if not original_win or not vim.api.nvim_win_is_valid(original_win) then - -- Original was closed (untracked file) — recreate it to the left of modified - vim.api.nvim_set_current_win(modified_win) - vim.cmd(config.options.diff.original_position == "right" and "rightbelow vsplit" or "leftabove vsplit") - original_win = vim.api.nvim_get_current_win() - vim.w[original_win].codediff_restore = 1 - session.original_win = original_win - elseif not modified_win or not vim.api.nvim_win_is_valid(modified_win) then - -- Modified was closed (deleted file) — recreate it to the right of original - vim.api.nvim_set_current_win(original_win) - vim.cmd(split_cmd) - modified_win = vim.api.nvim_get_current_win() - vim.w[modified_win].codediff_restore = 1 - session.modified_win = modified_win - end - - -- Clear single_pane AFTER new window has codediff_restore set - session.single_pane = nil - layout.arrange(tabpage) - end - - -- Determine if new buffers are virtual - local original_is_virtual = is_virtual_revision(session_config.original_revision) - local modified_is_virtual = is_virtual_revision(session_config.modified_revision) - - -- Prepare new buffer information - local original_info = prepare_buffer(original_is_virtual, session_config.git_root, session_config.original_revision, session_config.original) - local modified_info = prepare_buffer(modified_is_virtual, session_config.git_root, session_config.modified_revision, session_config.modified) - - -- Determine if we need to wait for virtual file content - local wait_state = { - original = original_is_virtual and original_info.needs_edit, - modified = modified_is_virtual and modified_info.needs_edit, - } - - local render_everything = function() - -- Guard: Check if windows are still valid - if not vim.api.nvim_win_is_valid(original_win) or not vim.api.nvim_win_is_valid(modified_win) then - return - end - - -- Guard: Check if buffers are still valid - if not vim.api.nvim_buf_is_valid(original_info.bufnr) or not vim.api.nvim_buf_is_valid(modified_info.bufnr) then - return - end - - -- Always read from buffers (single source of truth) - local original_lines = vim.api.nvim_buf_get_lines(original_info.bufnr, 0, -1, false) - local modified_lines = vim.api.nvim_buf_get_lines(modified_info.bufnr, 0, -1, false) - - local should_auto_scroll = auto_scroll_to_first_hunk == true - local lines_diff - - if session_config.conflict then - -- Conflict mode: Fetch base content and render both sides against base - local git = require("codediff.core.git") - local base_revision = ":1" - - git.get_file_content(base_revision, session_config.git_root, session_config.original.relative, function(err, base_lines) - if err then - base_lines = {} - end - - vim.schedule(function() - local conflict_diffs = - compute_and_render_conflict(original_info.bufnr, modified_info.bufnr, base_lines, original_lines, modified_lines, original_win, modified_win, should_auto_scroll) - - if conflict_diffs then - lifecycle.update_buffers(tabpage, original_info.bufnr, modified_info.bufnr) - lifecycle.update_git_root(tabpage, session_config.git_root) - lifecycle.update_revisions(tabpage, session_config.original_revision, session_config.modified_revision) - lifecycle.update_diff_result(tabpage, conflict_diffs.base_to_modified_diff) - lifecycle.update_changedtick(tabpage, vim.api.nvim_buf_get_changedtick(original_info.bufnr), vim.api.nvim_buf_get_changedtick(modified_info.bufnr)) - local is_explorer_mode = session.panel and session.panel.name == "explorer" - local success = setup_conflict_result_window(tabpage, session_config, original_win, modified_win, base_lines, conflict_diffs, true) - if success then - setup_all_keymaps(tabpage, original_info.bufnr, modified_info.bufnr, is_explorer_mode) - local conflict = require("codediff.ui.conflict") - conflict.setup_keymaps(tabpage) - end - end - end) - end) - else - -- Normal mode: Compute and render diff between left and right - lines_diff = compute_and_render( - original_info.bufnr, - modified_info.bufnr, - original_lines, - modified_lines, - original_is_virtual, - modified_is_virtual, - original_win, - modified_win, - should_auto_scroll, - session_config.line_range - ) - - if lines_diff then - lifecycle.update_buffers(tabpage, original_info.bufnr, modified_info.bufnr) - lifecycle.update_git_root(tabpage, session_config.git_root) - lifecycle.update_revisions(tabpage, session_config.original_revision, session_config.modified_revision) - lifecycle.update_diff_result(tabpage, lines_diff) - lifecycle.update_changedtick(tabpage, vim.api.nvim_buf_get_changedtick(original_info.bufnr), vim.api.nvim_buf_get_changedtick(modified_info.bufnr)) - setup_auto_refresh(original_info.bufnr, modified_info.bufnr, original_is_virtual, modified_is_virtual) - - local is_explorer_mode = session.panel and session.panel.name == "explorer" - setup_all_keymaps(tabpage, original_info.bufnr, modified_info.bufnr, is_explorer_mode) - - -- Restore focus to the window that was active before update - if saved_current_win and vim.api.nvim_win_is_valid(saved_current_win) then - vim.api.nvim_set_current_win(saved_current_win) - end - end - end - end - - -- Wait for virtual content before rendering; real files are ready already. - render_when_reloaded(tabpage, original_info, modified_info, wait_state, render_everything) - reload_side(original_win, original_info, original_is_virtual) - reload_side(modified_win, modified_info, modified_is_virtual) - - welcome_window.sync(original_win) - welcome_window.sync(modified_win) - - -- Update lifecycle session metadata - lifecycle.update_paths(tabpage, session_config.original, session_config.modified) - - -- Delete old virtual buffers if they were virtual AND are not reused - if lifecycle.is_original_virtual(tabpage) and old_original_buf ~= original_info.bufnr and old_original_buf ~= modified_info.bufnr then - pcall(vim.api.nvim_buf_delete, old_original_buf, { force = true }) - end - - if lifecycle.is_modified_virtual(tabpage) and old_modified_buf ~= modified_info.bufnr and old_modified_buf ~= original_info.bufnr then - pcall(vim.api.nvim_buf_delete, old_modified_buf, { force = true }) - end - - -- Nothing to wait for: render now. Otherwise render_when_reloaded does it. - if not (wait_state.original or wait_state.modified) then - vim.schedule(render_everything) - end - - return true -end - --- ============================================================================ --- Single-file display (no diff) for explorer special cases --- ============================================================================ - ---- True when the pane already shows exactly what this call would render. ---- Explorer refreshes re-select the file that is already open. For real diffs ---- on_file_select short-circuits that, but untracked/added/deleted files return ---- before reaching its guard, so the window was torn down and rebuilt on every ---- refresh, and the layout pass at the end of the rebuild discarded any pane ---- the user had resized. Comparing the displayed buffer covers path and ---- revision at once, since virtual revisions resolve to distinct buffers. ----@param session table ----@param opts table ----@return boolean -local function single_file_unchanged(session, opts) - if not session.single_pane then - return false - end - if session.single_side ~= (opts.highlight ~= false and opts.keep or nil) then - return false - end - local keep_win = opts.keep == "original" and session.original_win or session.modified_win - local other_win = opts.keep == "original" and session.modified_win or session.original_win - if other_win and vim.api.nvim_win_is_valid(other_win) then - return false - end - if not keep_win or not vim.api.nvim_win_is_valid(keep_win) then - return false - end - return vim.api.nvim_win_get_buf(keep_win) == opts.load_bufnr -end - ---- Core implementation for showing a single file without diff. ---- Closes the empty pane and loads the file into the remaining pane. ----@param tabpage number ----@param opts { keep: "original"|"modified", load_bufnr: number, original_path: string, modified_path: string, original_revision: string?, modified_revision: string?, highlight: boolean? } -local function show_single_file(tabpage, opts) - local session = lifecycle.get_session(tabpage) - if not session then - return - end - - if single_file_unchanged(session, opts) then - return - end - - lifecycle.update_layout(tabpage, "side-by-side") - local orig_win, mod_win = lifecycle.get_windows(tabpage) - - -- Clear highlights from current session buffers - local old_orig_buf, old_mod_buf = lifecycle.get_buffers(tabpage) - if old_orig_buf then - auto_refresh.disable(old_orig_buf) - lifecycle.clear_highlights(old_orig_buf) - end - if old_mod_buf then - auto_refresh.disable(old_mod_buf) - lifecycle.clear_highlights(old_mod_buf) - end - - -- Mark single-pane BEFORE closing window (prevents cleanup trigger) - session.single_pane = true - - -- Leaving conflict mode: close the result window too, mirroring M.update. - -- Without this the 3rd conflict pane survives under the single-file view, and - -- returning to the conflict file reuses that stale window whose buffer still - -- has unsaved merge edits, so `:edit` fails with E37. Closing is forced so it - -- also works when 'hidden' is off; the buffer only becomes hidden, never - -- unloaded, so in-progress merge edits are preserved. - local _, old_result_win = lifecycle.get_result(tabpage) - if old_result_win and vim.api.nvim_win_is_valid(old_result_win) then - vim.w[old_result_win].codediff_restore = nil - pcall(vim.api.nvim_win_close, old_result_win, true) - end - lifecycle.set_result(tabpage, nil, nil) - - -- Close the unused window - local keep_win, close_win - if opts.keep == "modified" then - keep_win, close_win = mod_win, orig_win - else - keep_win, close_win = orig_win, mod_win - end - - if keep_win == close_win then - close_win = nil - end - if (not keep_win or not vim.api.nvim_win_is_valid(keep_win)) and close_win and vim.api.nvim_win_is_valid(close_win) then - keep_win = close_win - close_win = nil - end - - -- Load the file into the kept window BEFORE closing the other one. Virtual - -- buffers (from load_virtual_file) carry `bufhidden = "wipe"` so they get - -- wiped as soon as they have no window; closing close_win first would leave - -- the freshly-created virtual buffer with no window, wiping it before we can - -- set it into keep_win — producing "Invalid buffer id" (#498). - if keep_win and vim.api.nvim_win_is_valid(keep_win) then - show_real_file_buffer(keep_win, opts.load_bufnr) - end - - if close_win and vim.api.nvim_win_is_valid(close_win) then - vim.w[close_win].codediff_restore = nil - vim.api.nvim_win_close(close_win, true) - close_win = nil - end - - if keep_win and vim.api.nvim_win_is_valid(keep_win) then - welcome_window.sync(keep_win) - - if opts.keep == "original" then - session.original_win = keep_win - session.modified_win = nil - else - session.original_win = nil - session.modified_win = keep_win - end - - -- Create a scratch buffer as placeholder for the empty side - local empty_buf = vim.api.nvim_create_buf(false, true) - vim.bo[empty_buf].buftype = "nofile" - - local orig_bufnr = opts.keep == "original" and opts.load_bufnr or empty_buf - local mod_bufnr = opts.keep == "modified" and opts.load_bufnr or empty_buf - - lifecycle.update_buffers(tabpage, orig_bufnr, mod_bufnr) - lifecycle.update_paths(tabpage, path.make_ref(opts.original_path or "", session.git_root), path.make_ref(opts.modified_path or "", session.git_root)) - lifecycle.update_revisions(tabpage, opts.original_revision, opts.modified_revision) - lifecycle.update_diff_result(tabpage, { changes = {}, moves = {} }) - session.single_side = opts.highlight ~= false and opts.keep or nil - if session.single_side then - core.render_whole_file(opts.load_bufnr, session.single_side) - end - - local view_keymaps = require("codediff.ui.view.keymaps") - view_keymaps.setup_all_keymaps(tabpage, orig_bufnr, mod_bufnr, session.panel ~= nil and session.panel.name == "explorer") - end - - layout.arrange(tabpage) - if keep_win and vim.api.nvim_win_is_valid(keep_win) then - welcome_window.sync_later(keep_win) - end -end - --- Load a real file from disk, return bufnr -local function load_real_file(file_path) - local bufnr = vim.fn.bufadd(file_path) - vim.fn.bufload(bufnr) - return bufnr -end - --- Load a virtual file from git revision, return bufnr -local function load_virtual_file(git_root, revision, file_path) - local virtual_file_mod = require("codediff.core.virtual_file") - local url = virtual_file_mod.create_url(git_root, revision, file_path) - local bufnr = vim.fn.bufadd(url) - vim.fn.bufload(bufnr) - return bufnr -end - ---- Show an untracked file (status "??") — modified pane only -function M.show_untracked_file(tabpage, file_path) - show_single_file(tabpage, { - keep = "modified", - load_bufnr = load_real_file(file_path), - file_path = file_path, - modified_path = file_path, - }) -end - ---- Show a deleted file (status "D", working tree) — original pane only -function M.show_deleted_file(tabpage, git_root, file_path, abs_path, group) - local revision = (group == "staged") and "HEAD" or ":0" - show_single_file(tabpage, { - keep = "original", - load_bufnr = load_virtual_file(git_root, revision, file_path), - file_path = abs_path, - load_revision = revision, - load_git_root = git_root, - rel_path = file_path, - original_path = abs_path, - original_revision = revision, - }) -end - ---- Show an added virtual file (status "A") — modified pane only -function M.show_added_virtual_file(tabpage, git_root, file_path, revision) - show_single_file(tabpage, { - keep = "modified", - load_bufnr = load_virtual_file(git_root, revision, file_path), - file_path = file_path, - load_revision = revision, - load_git_root = git_root, - rel_path = file_path, - modified_path = file_path, - modified_revision = revision, - }) -end - ---- Show a deleted virtual file (status "D", two-revision mode) — original pane only -function M.show_deleted_virtual_file(tabpage, git_root, file_path, revision) - show_single_file(tabpage, { - keep = "original", - load_bufnr = load_virtual_file(git_root, revision, file_path), - file_path = file_path, - load_revision = revision, - load_git_root = git_root, - rel_path = file_path, - original_path = file_path, - original_revision = revision, - }) -end - ---- Show the welcome page in a single pane (modified side) -function M.show_welcome(tabpage, load_bufnr) - show_single_file(tabpage, { - keep = "modified", - highlight = false, - load_bufnr = load_bufnr, - }) -end - -return M diff --git a/lua/codediff/ui/view/side_by_side/create.lua b/lua/codediff/ui/view/side_by_side/create.lua new file mode 100644 index 00000000..3462b5e6 --- /dev/null +++ b/lua/codediff/ui/view/side_by_side/create.lua @@ -0,0 +1,388 @@ +-- Creates side-by-side diff views. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local config = require("codediff.config") +local helpers = require("codediff.ui.view.helpers") +local readiness = require("codediff.ui.view.readiness") +local render = require("codediff.ui.view.render") +local conflict_view = require("codediff.ui.conflict.view") +local view_keymaps = require("codediff.ui.view.keymaps") +local panel = require("codediff.ui.view.panel") +local welcome_window = require("codediff.ui.view.welcome_window") + +local is_virtual_revision = helpers.is_virtual_revision +local prepare_buffer = helpers.prepare_buffer +local is_panel_placeholder = helpers.is_panel_placeholder +local show_real_file_buffer = helpers.show_real_file_buffer +local open_real_file = helpers.open_real_file +local compute_and_render = render.compute_and_render +local compute_and_render_conflict = conflict_view.compute_and_render_conflict +local setup_auto_refresh = render.setup_auto_refresh +local setup_conflict_result_window = conflict_view.setup_conflict_result_window +local setup_all_keymaps = view_keymaps.setup_all_keymaps + +--- Split direction that lands the modified pane on the side the user asked for. +--- Explicit rather than relying on 'splitright'. +--- @return string +local function diff_split_cmd() + return config.options.diff.original_position == "right" and "leftabove vsplit" or "rightbelow vsplit" +end + +--- Open the two panes with throwaway scratch buffers, for a session whose +--- content arrives later via the panel. +--- @param tabpage number +--- @return number original_win, number modified_win, table original_info, table modified_info +local function open_placeholder_panes(tabpage) + local original_win = vim.api.nvim_get_current_win() + vim.cmd(diff_split_cmd()) + local modified_win = vim.api.nvim_get_current_win() + + -- A buffer each, so the tab's initial buffer can be deleted afterwards. + local orig_scratch = vim.api.nvim_create_buf(false, true) + local mod_scratch = vim.api.nvim_create_buf(false, true) + vim.bo[orig_scratch].buftype = "nofile" + vim.bo[mod_scratch].buftype = "nofile" + pcall(vim.api.nvim_buf_set_name, orig_scratch, "CodeDiff " .. tabpage .. ".1") + pcall(vim.api.nvim_buf_set_name, mod_scratch, "CodeDiff " .. tabpage .. ".2") + vim.api.nvim_win_set_buf(original_win, orig_scratch) + vim.api.nvim_win_set_buf(modified_win, mod_scratch) + welcome_window.sync(original_win) + welcome_window.sync(modified_win) + + return original_win, modified_win, { bufnr = orig_scratch }, { bufnr = mod_scratch } +end + +--- Show one side's content in `win`, whether it is a git revision or a real file. +--- @param win number +--- @param info table From prepare_buffer +--- @param is_virtual boolean +local function load_side(win, info, is_virtual) + if is_virtual then + if info.needs_edit then + vim.cmd("edit! " .. vim.fn.fnameescape(info.target)) + info.bufnr = vim.api.nvim_get_current_buf() + else + vim.api.nvim_win_set_buf(win, info.bufnr) + end + elseif info.needs_edit then + info.bufnr = open_real_file(win, info.target) + else + show_real_file_buffer(win, info.bufnr) + end +end + +--- Open the two panes with the diff's actual content loaded. +--- @param session_config SessionConfig +--- @return number original_win, number modified_win, table original_info, table modified_info +local function open_diff_panes(session_config) + local original_is_virtual = is_virtual_revision(session_config.original_revision) + local modified_is_virtual = is_virtual_revision(session_config.modified_revision) + + local original_info = prepare_buffer(original_is_virtual, session_config.git_root, session_config.original_revision, session_config.original) + local modified_info = prepare_buffer(modified_is_virtual, session_config.git_root, session_config.modified_revision, session_config.modified) + + local original_win = vim.api.nvim_get_current_win() + load_side(original_win, original_info, original_is_virtual) + + vim.cmd(diff_split_cmd()) + local modified_win = vim.api.nvim_get_current_win() + load_side(modified_win, modified_info, modified_is_virtual) + + welcome_window.sync(original_win) + welcome_window.sync(modified_win) + + return original_win, modified_win, original_info, modified_info +end + +--- Window options both diff panes get. 'wrap' is load-bearing: the scroll-sync +--- maps one buffer line to one screen row. 'number'/'relativenumber' are left +--- alone so the user's own settings survive. +--- @param original_win number +--- @param modified_win number +local function apply_pane_options(original_win, modified_win) + local win_opts = { + cursorline = true, + wrap = false, + list = false, + } + for opt, val in pairs(win_opts) do + vim.wo[original_win][opt] = val + vim.wo[modified_win][opt] = val + end +end + +--- Reapply-keymaps callback stored on the session, so a shape change (panel +--- appearing, layout toggle) can reinstall the right mappings. +--- @param tabpage number +--- @param opts? table { conflict: boolean } +--- @return function +local function make_reapply_keymaps(tabpage, opts) + local is_conflict = opts and opts.conflict or false + return function() + local ob, mb = lifecycle.get_buffers(tabpage) + if not ob or not mb then + return + end + if is_conflict then + setup_all_keymaps(tabpage, ob, mb, false) + require("codediff.ui.conflict").setup_keymaps(tabpage) + else + setup_all_keymaps(tabpage, ob, mb, lifecycle.get_panel_name(tabpage) == "explorer") + end + end +end + +--- Attach the panels, announce the view, and describe it to the caller. +--- @param tabpage number +--- @param session_config SessionConfig +--- @param original_win number +--- @param modified_win number +--- @param original_info table +--- @param modified_info table +--- @return table +local function finish_create(tabpage, session_config, original_win, modified_win, original_info, modified_info) + panel.setup_explorer(tabpage, session_config, original_win, modified_win) + panel.setup_history(tabpage, session_config, original_win, modified_win) + + vim.api.nvim_exec_autocmds("User", { + pattern = "CodeDiffOpen", + modeline = false, + data = { + tabpage = tabpage, + mode = lifecycle.event_mode(session_config.panel), + }, + }) + + return { + original_buf = original_info.bufnr, + modified_buf = modified_info.bufnr, + original_win = original_win, + modified_win = modified_win, + } +end + +--- Render a 3-way merge: fetch the merge base, diff both sides against it, +--- then register the session and open the result pane. +--- @param ctx table { tabpage, session_config, wins, infos, lines, on_ready } +local function render_conflict_view(ctx) + local git = require("codediff.core.git") + local session_config = ctx.session_config + local tabpage = ctx.tabpage + local original_win, modified_win = ctx.original_win, ctx.modified_win + local original_info, modified_info = ctx.original_info, ctx.modified_info + + git.get_file_content(":1", session_config.git_root, session_config.original.relative, function(err, base_lines) + -- Add/add conflicts (AA) have no base version; treat it as empty. + if err then + base_lines = {} + end + + vim.schedule(function() + local conflict_diffs = compute_and_render_conflict( + original_info.bufnr, + modified_info.bufnr, + base_lines, + ctx.original_lines, + ctx.modified_lines, + original_win, + modified_win, + config.options.diff.jump_to_first_change + ) + if not conflict_diffs then + return + end + + lifecycle.create_session(tabpage, session_config, { + original_bufnr = original_info.bufnr, + modified_bufnr = modified_info.bufnr, + original_win = original_win, + modified_win = modified_win, + lines_diff = conflict_diffs.base_to_modified_diff, + reapply_keymaps = make_reapply_keymaps(tabpage, { conflict = true }), + }) + + local success = setup_conflict_result_window(tabpage, session_config, original_win, modified_win, base_lines, conflict_diffs, false) + if success then + setup_all_keymaps(tabpage, original_info.bufnr, modified_info.bufnr, false) + -- After setup_all_keymaps, so the conflict mappings win. + require("codediff.ui.conflict").setup_keymaps(tabpage) + end + + if ctx.on_ready then + ctx.on_ready() + end + end) + end) +end + +--- Render an ordinary two-pane diff and register the session. +--- @param ctx table { tabpage, session_config, wins, infos, lines, virtual flags, on_ready } +local function render_diff_view(ctx) + local session_config = ctx.session_config + local tabpage = ctx.tabpage + local original_info, modified_info = ctx.original_info, ctx.modified_info + + local lines_diff = compute_and_render( + original_info.bufnr, + modified_info.bufnr, + ctx.original_lines, + ctx.modified_lines, + ctx.original_is_virtual, + ctx.modified_is_virtual, + ctx.original_win, + ctx.modified_win, + config.options.diff.jump_to_first_change + ) + if not lines_diff then + return + end + + lifecycle.create_session(tabpage, session_config, { + original_bufnr = original_info.bufnr, + modified_bufnr = modified_info.bufnr, + original_win = ctx.original_win, + modified_win = ctx.modified_win, + lines_diff = lines_diff, + reapply_keymaps = make_reapply_keymaps(tabpage), + }) + + -- Real file buffers only; virtual ones never change under us. + setup_auto_refresh(original_info.bufnr, modified_info.bufnr, ctx.original_is_virtual, ctx.modified_is_virtual) + setup_all_keymaps(tabpage, original_info.bufnr, modified_info.bufnr, false) + require("codediff.ui.follow_working_file").enable(tabpage, ctx.original_is_virtual, ctx.modified_is_virtual) + + if ctx.on_ready then + ctx.on_ready() + end +end + +--- Run `render` once both panes hold their final content. Virtual buffers +--- load asynchronously via BufReadCmd; real files only need the pending :edit. +--- @param tabpage number +--- @param original_info table +--- @param modified_info table +--- @param original_is_virtual boolean +--- @param modified_is_virtual boolean +--- @param render function +local function render_when_loaded(tabpage, original_info, modified_info, original_is_virtual, modified_is_virtual, render) + local awaited = {} + if original_is_virtual then + awaited[#awaited + 1] = "original" + end + if modified_is_virtual then + awaited[#awaited + 1] = "modified" + end + if #awaited == 0 then + vim.schedule(render) + return + end + + local group = vim.api.nvim_create_augroup("CodeDiffVirtualFileHighlight_" .. tabpage, { clear = true }) + local ready = readiness.when_all(awaited, function() + vim.schedule(render) + vim.api.nvim_del_augroup_by_id(group) + end) + + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "CodeDiffVirtualFileLoaded", + callback = function(event) + local buf = event.data and event.data.buf + if not buf then + return + end + if original_is_virtual and buf == original_info.bufnr then + ready.done("original") + end + if modified_is_virtual and buf == modified_info.bufnr then + ready.done("modified") + end + end, + }) +end + +---@param session_config SessionConfig +---@param filetype? string +---@param on_ready? function +---@return table|nil +function M.create(session_config, filetype, on_ready) + vim.cmd("tabnew") + local tabpage = vim.api.nvim_get_current_tabpage() + local initial_buf = vim.api.nvim_get_current_buf() + + local placeholder = is_panel_placeholder(session_config) + local original_win, modified_win, original_info, modified_info + + if placeholder then + original_win, modified_win, original_info, modified_info = open_placeholder_panes(tabpage) + else + original_win, modified_win, original_info, modified_info = open_diff_panes(session_config) + end + + -- Clean up initial buffer + if vim.api.nvim_buf_is_valid(initial_buf) and initial_buf ~= original_info.bufnr and initial_buf ~= modified_info.bufnr then + pcall(vim.api.nvim_buf_delete, initial_buf, { force = true }) + end + + apply_pane_options(original_win, modified_win) + + if placeholder then + -- The panel populates this session on first file selection. + lifecycle.create_session(tabpage, session_config, { + original_bufnr = original_info.bufnr, + modified_bufnr = modified_info.bufnr, + original_win = original_win, + modified_win = modified_win, + lines_diff = {}, -- Empty diff result - will be updated on first file selection + reapply_keymaps = make_reapply_keymaps(tabpage), + }) + else + local original_is_virtual = is_virtual_revision(session_config.original_revision) + local modified_is_virtual = is_virtual_revision(session_config.modified_revision) + + local render = function() + -- The panes may have been closed, or the buffers wiped, while we waited. + if not vim.api.nvim_win_is_valid(original_win) or not vim.api.nvim_win_is_valid(modified_win) then + return + end + if not vim.api.nvim_buf_is_valid(original_info.bufnr) or not vim.api.nvim_buf_is_valid(modified_info.bufnr) then + return + end + + -- Called from vim.schedule, possibly with another tab current. syncbind + -- and friends act on the current tab, so switch to ours first. + local target_tab = vim.api.nvim_win_get_tabpage(modified_win) + if vim.api.nvim_get_current_tabpage() ~= target_tab then + vim.api.nvim_set_current_tabpage(target_tab) + end + + -- Read from the buffers, the single source of truth. + local ctx = { + tabpage = tabpage, + session_config = session_config, + original_win = original_win, + modified_win = modified_win, + original_info = original_info, + modified_info = modified_info, + original_lines = vim.api.nvim_buf_get_lines(original_info.bufnr, 0, -1, false), + modified_lines = vim.api.nvim_buf_get_lines(modified_info.bufnr, 0, -1, false), + original_is_virtual = original_is_virtual, + modified_is_virtual = modified_is_virtual, + on_ready = on_ready, + } + + if session_config.conflict then + render_conflict_view(ctx) + else + render_diff_view(ctx) + end + end + + render_when_loaded(tabpage, original_info, modified_info, original_is_virtual, modified_is_virtual, render) + end + + return finish_create(tabpage, session_config, original_win, modified_win, original_info, modified_info) +end + +return M diff --git a/lua/codediff/ui/view/side_by_side/init.lua b/lua/codediff/ui/view/side_by_side/init.lua new file mode 100644 index 00000000..78def26d --- /dev/null +++ b/lua/codediff/ui/view/side_by_side/init.lua @@ -0,0 +1,21 @@ +-- Side-by-side diff view engine. +local M = {} + +-- Eagerly load explorer and history to avoid lazy require failures +-- when CWD changes in vim.schedule callbacks. +local explorer_module = require("codediff.ui.explorer") +local history_module = require("codediff.ui.history") + +local create = require("codediff.ui.view.side_by_side.create") +local update = require("codediff.ui.view.side_by_side.update") +local single_file = require("codediff.ui.view.side_by_side.single_file") + +M.create = create.create +M.update = update.update +M.show_untracked_file = single_file.show_untracked_file +M.show_deleted_file = single_file.show_deleted_file +M.show_added_virtual_file = single_file.show_added_virtual_file +M.show_deleted_virtual_file = single_file.show_deleted_virtual_file +M.show_welcome = single_file.show_welcome + +return M diff --git a/lua/codediff/ui/view/side_by_side/single_file.lua b/lua/codediff/ui/view/side_by_side/single_file.lua new file mode 100644 index 00000000..81a51923 --- /dev/null +++ b/lua/codediff/ui/view/side_by_side/single_file.lua @@ -0,0 +1,232 @@ +-- Displays one-sided files in a side-by-side session. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local auto_refresh = require("codediff.ui.auto_refresh") +local core = require("codediff.ui.core") +local path = require("codediff.core.path") +local layout = require("codediff.ui.layout") +local helpers = require("codediff.ui.view.helpers") +local welcome_window = require("codediff.ui.view.welcome_window") + +local show_real_file_buffer = helpers.show_real_file_buffer + +--- True when the pane already shows exactly what this call would render. +--- Explorer refreshes re-select the file that is already open. For real diffs +--- on_file_select short-circuits that, but untracked/added/deleted files return +--- before reaching its guard, so the window was torn down and rebuilt on every +--- refresh, and the layout pass at the end of the rebuild discarded any pane +--- the user had resized. Comparing the displayed buffer covers path and +--- revision at once, since virtual revisions resolve to distinct buffers. +---@param session table +---@param opts table +---@return boolean +local function single_file_unchanged(session, opts) + if not session.single_pane then + return false + end + if session.single_side ~= (opts.highlight ~= false and opts.keep or nil) then + return false + end + local keep_win = opts.keep == "original" and session.original_win or session.modified_win + local other_win = opts.keep == "original" and session.modified_win or session.original_win + if other_win and vim.api.nvim_win_is_valid(other_win) then + return false + end + if not keep_win or not vim.api.nvim_win_is_valid(keep_win) then + return false + end + return vim.api.nvim_win_get_buf(keep_win) == opts.load_bufnr +end + +--- Core implementation for showing a single file without diff. +--- Closes the empty pane and loads the file into the remaining pane. +---@param tabpage number +---@param opts { keep: "original"|"modified", load_bufnr: number, original_path: string, modified_path: string, original_revision: string?, modified_revision: string?, highlight: boolean? } +local function show_single_file(tabpage, opts) + local session = lifecycle.get_session(tabpage) + if not session then + return + end + + if single_file_unchanged(session, opts) then + return + end + + lifecycle.update_layout(tabpage, "side-by-side") + local orig_win, mod_win = lifecycle.get_windows(tabpage) + + -- Clear highlights from current session buffers + local old_orig_buf, old_mod_buf = lifecycle.get_buffers(tabpage) + if old_orig_buf then + auto_refresh.disable(old_orig_buf) + lifecycle.clear_highlights(old_orig_buf) + end + if old_mod_buf then + auto_refresh.disable(old_mod_buf) + lifecycle.clear_highlights(old_mod_buf) + end + + -- Mark single-pane BEFORE closing window (prevents cleanup trigger) + session.single_pane = true + + -- Leaving conflict mode: close the result window too, mirroring M.update. + -- Without this the 3rd conflict pane survives under the single-file view, and + -- returning to the conflict file reuses that stale window whose buffer still + -- has unsaved merge edits, so `:edit` fails with E37. Closing is forced so it + -- also works when 'hidden' is off; the buffer only becomes hidden, never + -- unloaded, so in-progress merge edits are preserved. + local _, old_result_win = lifecycle.get_result(tabpage) + if old_result_win and vim.api.nvim_win_is_valid(old_result_win) then + vim.w[old_result_win].codediff_restore = nil + pcall(vim.api.nvim_win_close, old_result_win, true) + end + lifecycle.set_result(tabpage, nil, nil) + + -- Close the unused window + local keep_win, close_win + if opts.keep == "modified" then + keep_win, close_win = mod_win, orig_win + else + keep_win, close_win = orig_win, mod_win + end + + if keep_win == close_win then + close_win = nil + end + if (not keep_win or not vim.api.nvim_win_is_valid(keep_win)) and close_win and vim.api.nvim_win_is_valid(close_win) then + keep_win = close_win + close_win = nil + end + + -- Load the file into the kept window BEFORE closing the other one. Virtual + -- buffers (from load_virtual_file) carry `bufhidden = "wipe"` so they get + -- wiped as soon as they have no window; closing close_win first would leave + -- the freshly-created virtual buffer with no window, wiping it before we can + -- set it into keep_win — producing "Invalid buffer id" (#498). + if keep_win and vim.api.nvim_win_is_valid(keep_win) then + show_real_file_buffer(keep_win, opts.load_bufnr) + end + + if close_win and vim.api.nvim_win_is_valid(close_win) then + vim.w[close_win].codediff_restore = nil + vim.api.nvim_win_close(close_win, true) + close_win = nil + end + + if keep_win and vim.api.nvim_win_is_valid(keep_win) then + welcome_window.sync(keep_win) + + if opts.keep == "original" then + session.original_win = keep_win + session.modified_win = nil + else + session.original_win = nil + session.modified_win = keep_win + end + + -- Create a scratch buffer as placeholder for the empty side + local empty_buf = vim.api.nvim_create_buf(false, true) + vim.bo[empty_buf].buftype = "nofile" + + local orig_bufnr = opts.keep == "original" and opts.load_bufnr or empty_buf + local mod_bufnr = opts.keep == "modified" and opts.load_bufnr or empty_buf + + lifecycle.update_buffers(tabpage, orig_bufnr, mod_bufnr) + lifecycle.update_paths(tabpage, path.make_ref(opts.original_path or "", session.git_root), path.make_ref(opts.modified_path or "", session.git_root)) + lifecycle.update_revisions(tabpage, opts.original_revision, opts.modified_revision) + lifecycle.update_diff_result(tabpage, { changes = {}, moves = {} }) + session.single_side = opts.highlight ~= false and opts.keep or nil + if session.single_side then + core.render_whole_file(opts.load_bufnr, session.single_side) + end + + local view_keymaps = require("codediff.ui.view.keymaps") + view_keymaps.setup_all_keymaps(tabpage, orig_bufnr, mod_bufnr, session.panel ~= nil and session.panel.name == "explorer") + end + + layout.arrange(tabpage) + if keep_win and vim.api.nvim_win_is_valid(keep_win) then + welcome_window.sync_later(keep_win) + end +end + +-- Load a real file from disk, return bufnr +local function load_real_file(file_path) + local bufnr = vim.fn.bufadd(file_path) + vim.fn.bufload(bufnr) + return bufnr +end + +-- Load a virtual file from git revision, return bufnr +local function load_virtual_file(git_root, revision, file_path) + local virtual_file_mod = require("codediff.core.virtual_file") + local url = virtual_file_mod.create_url(git_root, revision, file_path) + local bufnr = vim.fn.bufadd(url) + vim.fn.bufload(bufnr) + return bufnr +end + +--- Show an untracked file (status "??") — modified pane only +function M.show_untracked_file(tabpage, file_path) + show_single_file(tabpage, { + keep = "modified", + load_bufnr = load_real_file(file_path), + file_path = file_path, + modified_path = file_path, + }) +end + +--- Show a deleted file (status "D", working tree) — original pane only +function M.show_deleted_file(tabpage, git_root, file_path, abs_path, group) + local revision = (group == "staged") and "HEAD" or ":0" + show_single_file(tabpage, { + keep = "original", + load_bufnr = load_virtual_file(git_root, revision, file_path), + file_path = abs_path, + load_revision = revision, + load_git_root = git_root, + rel_path = file_path, + original_path = abs_path, + original_revision = revision, + }) +end + +--- Show an added virtual file (status "A") — modified pane only +function M.show_added_virtual_file(tabpage, git_root, file_path, revision) + show_single_file(tabpage, { + keep = "modified", + load_bufnr = load_virtual_file(git_root, revision, file_path), + file_path = file_path, + load_revision = revision, + load_git_root = git_root, + rel_path = file_path, + modified_path = file_path, + modified_revision = revision, + }) +end + +--- Show a deleted virtual file (status "D", two-revision mode) — original pane only +function M.show_deleted_virtual_file(tabpage, git_root, file_path, revision) + show_single_file(tabpage, { + keep = "original", + load_bufnr = load_virtual_file(git_root, revision, file_path), + file_path = file_path, + load_revision = revision, + load_git_root = git_root, + rel_path = file_path, + original_path = file_path, + original_revision = revision, + }) +end + +--- Show the welcome page in a single pane (modified side) +function M.show_welcome(tabpage, load_bufnr) + show_single_file(tabpage, { + keep = "modified", + highlight = false, + load_bufnr = load_bufnr, + }) +end + +return M diff --git a/lua/codediff/ui/view/side_by_side/update.lua b/lua/codediff/ui/view/side_by_side/update.lua new file mode 100644 index 00000000..cc8edf26 --- /dev/null +++ b/lua/codediff/ui/view/side_by_side/update.lua @@ -0,0 +1,307 @@ +-- Updates existing side-by-side diff views. +local M = {} + +local lifecycle = require("codediff.ui.lifecycle") +local virtual_file = require("codediff.core.virtual_file") +local auto_refresh = require("codediff.ui.auto_refresh") +local config = require("codediff.config") +local layout = require("codediff.ui.layout") +local helpers = require("codediff.ui.view.helpers") +local readiness = require("codediff.ui.view.readiness") +local render = require("codediff.ui.view.render") +local conflict_view = require("codediff.ui.conflict.view") +local view_keymaps = require("codediff.ui.view.keymaps") +local welcome_window = require("codediff.ui.view.welcome_window") + +local is_virtual_revision = helpers.is_virtual_revision +local prepare_buffer = helpers.prepare_buffer +local show_real_file_buffer = helpers.show_real_file_buffer +local open_real_file = helpers.open_real_file +local compute_and_render = render.compute_and_render +local compute_and_render_conflict = conflict_view.compute_and_render_conflict +local setup_auto_refresh = render.setup_auto_refresh +local setup_conflict_result_window = conflict_view.setup_conflict_result_window +local setup_all_keymaps = view_keymaps.setup_all_keymaps + +--- Put one side's content into `win` during an update, reusing the buffer when +--- it is still alive. Unlike load_side, this has to cope with the buffer having +--- been wiped since the session was built. +--- @param win number +--- @param info table From prepare_buffer; info.bufnr is updated in place +--- @param is_virtual boolean +local function reload_side(win, info, is_virtual) + if not vim.api.nvim_win_is_valid(win) then + return + end + + local function edit_in_place() + vim.api.nvim_set_current_win(win) + vim.cmd("edit! " .. vim.fn.fnameescape(info.target)) + info.bufnr = vim.api.nvim_get_current_buf() + end + + if info.needs_edit then + if not is_virtual then + info.bufnr = open_real_file(win, info.target) + elseif info.bufnr and vim.api.nvim_buf_is_valid(info.bufnr) then + vim.api.nvim_win_set_buf(win, info.bufnr) + virtual_file.refresh_buffer(info.bufnr) + else + edit_in_place() + end + return + end + + if vim.api.nvim_buf_is_valid(info.bufnr) then + if is_virtual then + vim.api.nvim_win_set_buf(win, info.bufnr) + else + show_real_file_buffer(win, info.bufnr) + end + elseif is_virtual then + edit_in_place() + else + info.bufnr = open_real_file(win, info.target) + end +end + +--- Run `render` once every side that needs loading has loaded. +--- @param tabpage number +--- @param original_info table +--- @param modified_info table +--- @param wait_state table { original: boolean, modified: boolean } +--- @param render function +local function render_when_reloaded(tabpage, original_info, modified_info, wait_state, render) + local awaited = {} + if wait_state.original then + awaited[#awaited + 1] = "original" + end + if wait_state.modified then + awaited[#awaited + 1] = "modified" + end + if #awaited == 0 then + return + end + + local group = vim.api.nvim_create_augroup("CodeDiffVirtualFileUpdate_" .. tabpage, { clear = true }) + local ready = readiness.when_all(awaited, function() + vim.schedule(render) + vim.api.nvim_del_augroup_by_id(group) + end) + + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "CodeDiffVirtualFileLoaded", + callback = function(event) + local buf = event.data and event.data.buf + if not buf then + return + end + if buf == original_info.bufnr then + ready.done("original") + end + if buf == modified_info.bufnr then + ready.done("modified") + end + end, + }) +end + +---@param tabpage number +---@param session_config SessionConfig +---@param auto_scroll_to_first_hunk boolean? +---@return boolean +function M.update(tabpage, session_config, auto_scroll_to_first_hunk) + -- Save current window to restore focus after update + local saved_current_win = vim.api.nvim_get_current_win() + + -- Get existing session + local session = lifecycle.get_session(tabpage) + if not session then + return false + end + session.single_side = nil + + -- Get existing buffers and windows + local old_original_buf, old_modified_buf = lifecycle.get_buffers(tabpage) + local original_win, modified_win = lifecycle.get_windows(tabpage) + + if not old_original_buf or not old_modified_buf then + return false + end + if not original_win and not modified_win then + return false + end + + -- Disable auto-refresh temporarily + auto_refresh.disable(old_original_buf) + auto_refresh.disable(old_modified_buf) + + -- Clear highlights from old buffers (before they're replaced/deleted) + lifecycle.clear_highlights(old_original_buf) + lifecycle.clear_highlights(old_modified_buf) + + -- Clear stored_diff_result to signal that an update is in progress + lifecycle.update_diff_result(tabpage, nil) + + -- Retargeting can move a session between a conflicted file and an ordinary + -- one, so the merge flag follows the incoming config. + lifecycle.update_merge(tabpage, session_config.conflict) + + -- Handle result window when switching between conflict and non-conflict modes + local old_result_bufnr, old_result_win = lifecycle.get_result(tabpage) + if not session_config.conflict and old_result_win and vim.api.nvim_win_is_valid(old_result_win) then + vim.api.nvim_win_close(old_result_win, false) + lifecycle.set_result(tabpage, nil, nil) + end + + -- Restore second window if returning from single-pane mode + if session.single_pane then + local split_cmd = config.options.diff.original_position == "right" and "leftabove vsplit" or "rightbelow vsplit" + + if not original_win or not vim.api.nvim_win_is_valid(original_win) then + -- Original was closed (untracked file) — recreate it to the left of modified + vim.api.nvim_set_current_win(modified_win) + vim.cmd(config.options.diff.original_position == "right" and "rightbelow vsplit" or "leftabove vsplit") + original_win = vim.api.nvim_get_current_win() + vim.w[original_win].codediff_restore = 1 + session.original_win = original_win + elseif not modified_win or not vim.api.nvim_win_is_valid(modified_win) then + -- Modified was closed (deleted file) — recreate it to the right of original + vim.api.nvim_set_current_win(original_win) + vim.cmd(split_cmd) + modified_win = vim.api.nvim_get_current_win() + vim.w[modified_win].codediff_restore = 1 + session.modified_win = modified_win + end + + -- Clear single_pane AFTER new window has codediff_restore set + session.single_pane = nil + layout.arrange(tabpage) + end + + -- Determine if new buffers are virtual + local original_is_virtual = is_virtual_revision(session_config.original_revision) + local modified_is_virtual = is_virtual_revision(session_config.modified_revision) + + -- Prepare new buffer information + local original_info = prepare_buffer(original_is_virtual, session_config.git_root, session_config.original_revision, session_config.original) + local modified_info = prepare_buffer(modified_is_virtual, session_config.git_root, session_config.modified_revision, session_config.modified) + + -- Determine if we need to wait for virtual file content + local wait_state = { + original = original_is_virtual and original_info.needs_edit, + modified = modified_is_virtual and modified_info.needs_edit, + } + + local render_everything = function() + -- Guard: Check if windows are still valid + if not vim.api.nvim_win_is_valid(original_win) or not vim.api.nvim_win_is_valid(modified_win) then + return + end + + -- Guard: Check if buffers are still valid + if not vim.api.nvim_buf_is_valid(original_info.bufnr) or not vim.api.nvim_buf_is_valid(modified_info.bufnr) then + return + end + + -- Always read from buffers (single source of truth) + local original_lines = vim.api.nvim_buf_get_lines(original_info.bufnr, 0, -1, false) + local modified_lines = vim.api.nvim_buf_get_lines(modified_info.bufnr, 0, -1, false) + + local should_auto_scroll = auto_scroll_to_first_hunk == true + local lines_diff + + if session_config.conflict then + -- Conflict mode: Fetch base content and render both sides against base + local git = require("codediff.core.git") + local base_revision = ":1" + + git.get_file_content(base_revision, session_config.git_root, session_config.original.relative, function(err, base_lines) + if err then + base_lines = {} + end + + vim.schedule(function() + local conflict_diffs = + compute_and_render_conflict(original_info.bufnr, modified_info.bufnr, base_lines, original_lines, modified_lines, original_win, modified_win, should_auto_scroll) + + if conflict_diffs then + lifecycle.update_buffers(tabpage, original_info.bufnr, modified_info.bufnr) + lifecycle.update_git_root(tabpage, session_config.git_root) + lifecycle.update_revisions(tabpage, session_config.original_revision, session_config.modified_revision) + lifecycle.update_diff_result(tabpage, conflict_diffs.base_to_modified_diff) + lifecycle.update_changedtick(tabpage, vim.api.nvim_buf_get_changedtick(original_info.bufnr), vim.api.nvim_buf_get_changedtick(modified_info.bufnr)) + local is_explorer_mode = session.panel and session.panel.name == "explorer" + local success = setup_conflict_result_window(tabpage, session_config, original_win, modified_win, base_lines, conflict_diffs, true) + if success then + setup_all_keymaps(tabpage, original_info.bufnr, modified_info.bufnr, is_explorer_mode) + local conflict = require("codediff.ui.conflict") + conflict.setup_keymaps(tabpage) + end + end + end) + end) + else + -- Normal mode: Compute and render diff between left and right + lines_diff = compute_and_render( + original_info.bufnr, + modified_info.bufnr, + original_lines, + modified_lines, + original_is_virtual, + modified_is_virtual, + original_win, + modified_win, + should_auto_scroll, + session_config.line_range + ) + + if lines_diff then + lifecycle.update_buffers(tabpage, original_info.bufnr, modified_info.bufnr) + lifecycle.update_git_root(tabpage, session_config.git_root) + lifecycle.update_revisions(tabpage, session_config.original_revision, session_config.modified_revision) + lifecycle.update_diff_result(tabpage, lines_diff) + lifecycle.update_changedtick(tabpage, vim.api.nvim_buf_get_changedtick(original_info.bufnr), vim.api.nvim_buf_get_changedtick(modified_info.bufnr)) + setup_auto_refresh(original_info.bufnr, modified_info.bufnr, original_is_virtual, modified_is_virtual) + + local is_explorer_mode = session.panel and session.panel.name == "explorer" + setup_all_keymaps(tabpage, original_info.bufnr, modified_info.bufnr, is_explorer_mode) + + -- Restore focus to the window that was active before update + if saved_current_win and vim.api.nvim_win_is_valid(saved_current_win) then + vim.api.nvim_set_current_win(saved_current_win) + end + end + end + end + + -- Wait for virtual content before rendering; real files are ready already. + render_when_reloaded(tabpage, original_info, modified_info, wait_state, render_everything) + reload_side(original_win, original_info, original_is_virtual) + reload_side(modified_win, modified_info, modified_is_virtual) + + welcome_window.sync(original_win) + welcome_window.sync(modified_win) + + -- Update lifecycle session metadata + lifecycle.update_paths(tabpage, session_config.original, session_config.modified) + + -- Delete old virtual buffers if they were virtual AND are not reused + if lifecycle.is_original_virtual(tabpage) and old_original_buf ~= original_info.bufnr and old_original_buf ~= modified_info.bufnr then + pcall(vim.api.nvim_buf_delete, old_original_buf, { force = true }) + end + + if lifecycle.is_modified_virtual(tabpage) and old_modified_buf ~= modified_info.bufnr and old_modified_buf ~= original_info.bufnr then + pcall(vim.api.nvim_buf_delete, old_modified_buf, { force = true }) + end + + -- Nothing to wait for: render now. Otherwise render_when_reloaded does it. + if not (wait_state.original or wait_state.modified) then + vim.schedule(render_everything) + end + + return true +end + +return M diff --git a/tests/module_loading_spec.lua b/tests/module_loading_spec.lua index 6cbec28f..15bae4e4 100644 --- a/tests/module_loading_spec.lua +++ b/tests/module_loading_spec.lua @@ -118,6 +118,33 @@ describe("Explorer submodules", function() end) end) +-- ── view engines ─────────────────────────────────────────────────────── +describe("Side-by-side view module", function() + it("loads the facade and exports its existing API", function() + local ok, mod = pcall(require, "codediff.ui.view.side_by_side") + assert.is_true(ok, "Failed to require codediff.ui.view.side_by_side: " .. tostring(mod)) + assert.is_function(mod.create) + assert.is_function(mod.update) + assert.is_function(mod.show_untracked_file) + assert.is_function(mod.show_deleted_file) + assert.is_function(mod.show_added_virtual_file) + assert.is_function(mod.show_deleted_virtual_file) + assert.is_function(mod.show_welcome) + end) +end) + +describe("Inline view module", function() + it("loads the facade and exports its existing API", function() + local ok, mod = pcall(require, "codediff.ui.view.inline_view") + assert.is_true(ok, "Failed to require codediff.ui.view.inline_view: " .. tostring(mod)) + assert.is_function(mod.create) + assert.is_function(mod.update) + assert.is_function(mod.rerender) + assert.is_function(mod.show_single_file) + assert.is_function(mod.show_welcome) + end) +end) + -- ── lifecycle ─────────────────────────────────────────────────────────── describe("Lifecycle submodules", function() describe("module loading", function() @@ -246,9 +273,21 @@ end) -- ── conflict ──────────────────────────────────────────────────────────── describe("Conflict submodules", function() describe("module loading", function() - it("loads actions module", function() - local ok, mod = pcall(require, "codediff.ui.conflict.actions") - assert.is_true(ok, "Failed to require codediff.ui.conflict.actions") + it("loads merge module", function() + local ok, mod = pcall(require, "codediff.ui.conflict.merge") + assert.is_true(ok, "Failed to require codediff.ui.conflict.merge") + assert.is_not_nil(mod) + end) + + it("loads view module", function() + local ok, mod = pcall(require, "codediff.ui.conflict.view") + assert.is_true(ok, "Failed to require codediff.ui.conflict.view") + assert.is_not_nil(mod) + end) + + it("loads resolution module", function() + local ok, mod = pcall(require, "codediff.ui.conflict.resolution") + assert.is_true(ok, "Failed to require codediff.ui.conflict.resolution") assert.is_not_nil(mod) end) @@ -270,12 +309,6 @@ describe("Conflict submodules", function() assert.is_not_nil(mod) end) - it("loads diffget module", function() - local ok, mod = pcall(require, "codediff.ui.conflict.diffget") - assert.is_true(ok, "Failed to require codediff.ui.conflict.diffget") - assert.is_not_nil(mod) - end) - it("loads init facade", function() local ok, mod = pcall(require, "codediff.ui.conflict") assert.is_true(ok, "Failed to require codediff.ui.conflict") @@ -284,8 +317,33 @@ describe("Conflict submodules", function() end) describe("public API", function() - it("actions exports expected functions", function() - local mod = require("codediff.ui.conflict.actions") + it("merge facade delegates expected functions", function() + local mod = require("codediff.ui.conflict.merge") + local fillers = require("codediff.ui.conflict.merge.fillers") + local auto_merge = require("codediff.ui.conflict.merge.auto_merge") + assert.is_function(mod.compute_merge_fillers) + assert.is_function(mod.compute_merge_fillers_and_conflicts) + assert.is_function(mod.compute_auto_merged_result) + assert.equal(fillers.compute_merge_fillers, mod.compute_merge_fillers) + assert.equal(fillers.compute_merge_fillers_and_conflicts, mod.compute_merge_fillers_and_conflicts) + assert.equal(auto_merge.compute_auto_merged_result, mod.compute_auto_merged_result) + end) + + it("view facade delegates expected functions", function() + local mod = require("codediff.ui.conflict.view") + local inputs = require("codediff.ui.conflict.view.inputs") + local result = require("codediff.ui.conflict.view.result") + assert.is_function(mod.compute_and_render_conflict) + assert.is_function(mod.setup_conflict_result_window) + assert.equal(inputs.compute_and_render_conflict, mod.compute_and_render_conflict) + assert.equal(result.setup_conflict_result_window, mod.setup_conflict_result_window) + end) + + it("resolution facade delegates expected functions", function() + local mod = require("codediff.ui.conflict.resolution") + local block = require("codediff.ui.conflict.resolution.block") + local file = require("codediff.ui.conflict.resolution.file") + local diffget = require("codediff.ui.conflict.resolution.diffget") assert.is_function(mod.accept_incoming) assert.is_function(mod.accept_current) assert.is_function(mod.accept_both) @@ -294,6 +352,18 @@ describe("Conflict submodules", function() assert.is_function(mod.accept_all_current) assert.is_function(mod.accept_all_both) assert.is_function(mod.discard_all) + assert.is_function(mod.diffget_incoming) + assert.is_function(mod.diffget_current) + assert.equal(block.accept_incoming, mod.accept_incoming) + assert.equal(block.accept_current, mod.accept_current) + assert.equal(block.accept_both, mod.accept_both) + assert.equal(block.discard, mod.discard) + assert.equal(file.accept_all_incoming, mod.accept_all_incoming) + assert.equal(file.accept_all_current, mod.accept_all_current) + assert.equal(file.accept_all_both, mod.accept_all_both) + assert.equal(file.discard_all, mod.discard_all) + assert.equal(diffget.diffget_incoming, mod.diffget_incoming) + assert.equal(diffget.diffget_current, mod.diffget_current) end) it("keymaps exports setup function", function() @@ -313,10 +383,35 @@ describe("Conflict submodules", function() assert.is_function(mod.setup_sign_refresh_autocmd) end) - it("diffget exports expected functions", function() - local mod = require("codediff.ui.conflict.diffget") + 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 resolution = require("codediff.ui.conflict.resolution") + local navigation = require("codediff.ui.conflict.navigation") + local keymaps = require("codediff.ui.conflict.keymaps") + assert.is_function(mod.run_repeatable_action) + assert.is_function(mod.initialize_tracking) + assert.is_function(mod.accept_incoming) assert.is_function(mod.diffget_incoming) - assert.is_function(mod.diffget_current) + 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(resolution.accept_incoming, mod.accept_incoming) + assert.equal(resolution.accept_current, mod.accept_current) + assert.equal(resolution.accept_both, mod.accept_both) + assert.equal(resolution.discard, mod.discard) + assert.equal(resolution.accept_all_incoming, mod.accept_all_incoming) + assert.equal(resolution.accept_all_current, mod.accept_all_current) + assert.equal(resolution.accept_all_both, mod.accept_all_both) + assert.equal(resolution.discard_all, mod.discard_all) + assert.equal(resolution.diffget_incoming, mod.diffget_incoming) + assert.equal(resolution.diffget_current, mod.diffget_current) + assert.equal(navigation.navigate_next_conflict, mod.navigate_next_conflict) + assert.equal(navigation.navigate_prev_conflict, mod.navigate_prev_conflict) + assert.equal(keymaps.setup_keymaps, mod.setup_keymaps) end) end) end) diff --git a/tests/ui/conflict/conflict_layout_spec.lua b/tests/ui/conflict/conflict_layout_spec.lua new file mode 100644 index 00000000..9435055d --- /dev/null +++ b/tests/ui/conflict/conflict_layout_spec.lua @@ -0,0 +1,85 @@ +local h = dofile("tests/helpers.lua") +local path = require("codediff.core.path") + +describe("conflict editor layout", function() + local repo + local saved_layout + local saved_result_position + + before_each(function() + h.ensure_plugin_loaded() + local config = require("codediff.config") + saved_layout = config.options.diff.layout + saved_result_position = config.options.diff.conflict_result_position + config.options.diff.layout = "side-by-side" + + repo = h.create_temp_git_repo() + repo.write_file("file.txt", { "base" }) + repo.git("add file.txt") + repo.git("commit -m base") + repo.git("checkout -b feature") + repo.write_file("file.txt", { "feature" }) + repo.git("commit -am feature") + repo.git("checkout main") + repo.write_file("file.txt", { "main" }) + repo.git("commit -am main") + local merge_output = repo.git("merge feature --no-edit") + assert.is_true(merge_output:find("CONFLICT", 1, true) ~= nil, "merge must conflict") + end) + + after_each(function() + local config = require("codediff.config") + config.options.diff.layout = saved_layout + config.options.diff.conflict_result_position = saved_result_position + require("codediff.ui.lifecycle").cleanup_all() + while vim.fn.tabpagenr("$") > 1 do + vim.cmd("tabclose!") + end + if repo then + repo.cleanup() + end + end) + + it("places Result between the two inputs in center mode", function() + require("codediff.config").options.diff.conflict_result_position = "center" + vim.cmd("edit " .. vim.fn.fnameescape(repo.path("file.txt"))) + + local ready = false + require("codediff.ui.view").create({ + git_root = repo.dir, + original = path.make_ref("file.txt", repo.dir), + modified = path.make_ref("file.txt", repo.dir), + original_revision = ":3", + modified_revision = ":2", + conflict = true, + }, nil, function() + ready = true + end) + + local lifecycle = require("codediff.ui.lifecycle") + local tabpage = vim.api.nvim_get_current_tabpage() + local session + assert.is_true( + vim.wait(15000, function() + session = lifecycle.get_session(tabpage) + return ready + and session + and session.original_win + and session.modified_win + and session.result_win + and vim.api.nvim_win_is_valid(session.original_win) + and vim.api.nvim_win_is_valid(session.modified_win) + and vim.api.nvim_win_is_valid(session.result_win) + end, 50), + "center conflict editor never became ready" + ) + + local original_position = vim.api.nvim_win_get_position(session.original_win) + local modified_position = vim.api.nvim_win_get_position(session.modified_win) + local result_position = vim.api.nvim_win_get_position(session.result_win) + assert.equals(original_position[1], result_position[1]) + assert.equals(modified_position[1], result_position[1]) + assert.is_true(result_position[2] > math.min(original_position[2], modified_position[2])) + assert.is_true(result_position[2] < math.max(original_position[2], modified_position[2])) + end) +end) diff --git a/tests/ui/conflict/conflict_navigation_spec.lua b/tests/ui/conflict/conflict_navigation_spec.lua new file mode 100644 index 00000000..03cdb7ec --- /dev/null +++ b/tests/ui/conflict/conflict_navigation_spec.lua @@ -0,0 +1,73 @@ +local conflict = require("codediff.ui.conflict") +local lifecycle = require("codediff.ui.lifecycle") + +describe("conflict navigation", function() + local original_get_session + local tabpage + local session + local buffers + + local function new_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + buffers[#buffers + 1] = bufnr + return bufnr + end + + before_each(function() + original_get_session = lifecycle.get_session + tabpage = vim.api.nvim_get_current_tabpage() + buffers = {} + + local lines = { "one", "two", "three", "four", "five" } + local blocks = { + { + base_range = { start_line = 1, end_line = 2 }, + result_range = { start_line = 1, end_line = 2 }, + output1_range = { start_line = 1, end_line = 2 }, + output2_range = { start_line = 1, end_line = 2 }, + }, + { + base_range = { start_line = 4, end_line = 5 }, + result_range = { start_line = 4, end_line = 5 }, + output1_range = { start_line = 4, end_line = 5 }, + output2_range = { start_line = 4, end_line = 5 }, + }, + } + session = { + result_bufnr = new_buffer(vim.deepcopy(lines)), + original_bufnr = new_buffer(vim.deepcopy(lines)), + modified_bufnr = new_buffer(vim.deepcopy(lines)), + result_base_lines = vim.deepcopy(lines), + conflict_blocks = blocks, + } + lifecycle.get_session = function(candidate) + return candidate == tabpage and session or nil + end + conflict.initialize_tracking(session.result_bufnr, blocks) + end) + + after_each(function() + lifecycle.get_session = original_get_session + vim.api.nvim_set_current_buf(vim.api.nvim_create_buf(false, true)) + for _, bufnr in ipairs(buffers) do + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + end) + + it("moves forward, wraps, and moves backward across active blocks", function() + vim.api.nvim_set_current_buf(session.original_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + conflict.navigate_next_conflict(tabpage) + assert.equals(4, vim.api.nvim_win_get_cursor(0)[1]) + + conflict.navigate_next_conflict(tabpage) + assert.equals(1, vim.api.nvim_win_get_cursor(0)[1]) + + conflict.navigate_prev_conflict(tabpage) + assert.equals(4, vim.api.nvim_win_get_cursor(0)[1]) + end) +end) diff --git a/tests/ui/conflict/conflict_resolution_spec.lua b/tests/ui/conflict/conflict_resolution_spec.lua new file mode 100644 index 00000000..227867f7 --- /dev/null +++ b/tests/ui/conflict/conflict_resolution_spec.lua @@ -0,0 +1,133 @@ +local conflict = require("codediff.ui.conflict") +local lifecycle = require("codediff.ui.lifecycle") +local tracking = require("codediff.ui.conflict.tracking") + +describe("conflict resolution commands", function() + local original_get_session + local original_operatorfunc + local tabpage + local session + local block + local buffers + + local function new_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + buffers[#buffers + 1] = bufnr + return bufnr + end + + local function configure(opts) + local base_lines = opts.base_lines or { "base" } + block = { + base_range = { start_line = 1, end_line = 2 }, + result_range = { start_line = 1, end_line = 2 }, + output1_range = { start_line = 1, end_line = 2 }, + output2_range = { start_line = 1, end_line = 2 }, + inner1 = opts.inner1 or {}, + inner2 = opts.inner2 or {}, + } + session = { + result_bufnr = new_buffer(vim.deepcopy(base_lines)), + original_bufnr = new_buffer(opts.incoming_lines or { "incoming" }), + modified_bufnr = new_buffer(opts.current_lines or { "current" }), + result_base_lines = vim.deepcopy(base_lines), + merge_base_lines = vim.deepcopy(base_lines), + conflict_blocks = { block }, + } + lifecycle.get_session = function(candidate) + return candidate == tabpage and session or nil + end + conflict.initialize_tracking(session.result_bufnr, session.conflict_blocks) + end + + before_each(function() + original_get_session = lifecycle.get_session + original_operatorfunc = vim.go.operatorfunc + tabpage = vim.api.nvim_get_current_tabpage() + buffers = {} + end) + + after_each(function() + lifecycle.get_session = original_get_session + vim.go.operatorfunc = original_operatorfunc + vim.api.nvim_set_current_buf(vim.api.nvim_create_buf(false, true)) + for _, bufnr in ipairs(buffers) do + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + end) + + it("accepts the current side for the selected block", function() + configure({}) + vim.api.nvim_set_current_buf(session.modified_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + assert.is_true(conflict.accept_current(tabpage)) + assert.same({ "current" }, vim.api.nvim_buf_get_lines(session.result_bufnr, 0, -1, false)) + end) + + it("smart-combines non-overlapping edits from both sides", function() + configure({ + base_lines = { "abc" }, + incoming_lines = { "aXbc" }, + current_lines = { "abYc" }, + inner1 = { + { + original = { start_line = 1, start_col = 2, end_line = 1, end_col = 2 }, + modified = { start_line = 1, start_col = 2, end_line = 1, end_col = 3 }, + }, + }, + inner2 = { + { + original = { start_line = 1, start_col = 3, end_line = 1, end_col = 3 }, + modified = { start_line = 1, start_col = 3, end_line = 1, end_col = 4 }, + }, + }, + }) + vim.api.nvim_set_current_buf(session.original_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + assert.is_true(conflict.accept_both(tabpage)) + assert.same({ "aXbYc" }, vim.api.nvim_buf_get_lines(session.result_bufnr, 0, -1, false)) + end) + + it("concatenates both sides when smart combination is unavailable", function() + configure({}) + vim.api.nvim_set_current_buf(session.original_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + assert.is_true(conflict.accept_both(tabpage)) + assert.same({ "incoming", "current" }, vim.api.nvim_buf_get_lines(session.result_bufnr, 0, -1, false)) + end) + + it("gets the incoming side from the Result buffer", function() + configure({}) + vim.api.nvim_set_current_buf(session.result_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + assert.is_true(conflict.diffget_incoming(tabpage)) + assert.same({ "incoming" }, vim.api.nvim_buf_get_lines(session.result_bufnr, 0, -1, false)) + end) + + it("gets the current side from the Result buffer", function() + configure({}) + vim.api.nvim_set_current_buf(session.result_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + assert.is_true(conflict.diffget_current(tabpage)) + assert.same({ "current" }, vim.api.nvim_buf_get_lines(session.result_bufnr, 0, -1, false)) + end) + + it("repeats the most recently prepared resolution", function() + local calls = 0 + local repeatable = tracking.make_repeatable(function() + calls = calls + 1 + end) + + assert.equals("g@l", repeatable()) + conflict.run_repeatable_action("line") + assert.equals(1, calls) + end) +end) diff --git a/tests/ui/conflict/conflict_signs_spec.lua b/tests/ui/conflict/conflict_signs_spec.lua new file mode 100644 index 00000000..fec1f09b --- /dev/null +++ b/tests/ui/conflict/conflict_signs_spec.lua @@ -0,0 +1,74 @@ +local conflict = require("codediff.ui.conflict") +local lifecycle = require("codediff.ui.lifecycle") +local tracking = require("codediff.ui.conflict.tracking") +local highlights = require("codediff.ui.highlights") + +describe("conflict signs", function() + local original_get_session + local tabpage + local session + local buffers + + local function new_buffer(lines) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + buffers[#buffers + 1] = bufnr + return bufnr + end + + local function sign_highlight(bufnr, namespace) + local marks = vim.api.nvim_buf_get_extmarks(bufnr, namespace, 0, -1, { details = true }) + assert.is_true(#marks > 0, "expected a conflict sign") + return marks[1][4].sign_hl_group + end + + before_each(function() + original_get_session = lifecycle.get_session + tabpage = vim.api.nvim_get_current_tabpage() + buffers = {} + + local block = { + base_range = { start_line = 1, end_line = 2 }, + result_range = { start_line = 1, end_line = 2 }, + output1_range = { start_line = 1, end_line = 2 }, + output2_range = { start_line = 1, end_line = 2 }, + } + session = { + result_bufnr = new_buffer({ "base" }), + original_bufnr = new_buffer({ "incoming" }), + modified_bufnr = new_buffer({ "current" }), + result_base_lines = { "base" }, + merge_base_lines = { "base" }, + conflict_blocks = { block }, + } + lifecycle.get_session = function(candidate) + return candidate == tabpage and session or nil + end + conflict.initialize_tracking(session.result_bufnr, session.conflict_blocks) + end) + + after_each(function() + lifecycle.get_session = original_get_session + vim.api.nvim_set_current_buf(vim.api.nvim_create_buf(false, true)) + for _, bufnr in ipairs(buffers) do + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + end) + + it("changes input and Result signs after accepting one side", function() + conflict.refresh_all_conflict_signs(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)) + + vim.api.nvim_set_current_buf(session.original_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + assert.is_true(conflict.accept_incoming(tabpage)) + + assert.equals("CodeDiffConflictSignAccepted", sign_highlight(session.original_bufnr, highlights.ns_conflict)) + assert.equals("CodeDiffConflictSignRejected", sign_highlight(session.modified_bufnr, highlights.ns_conflict)) + assert.equals("CodeDiffConflictSignResolved", sign_highlight(session.result_bufnr, tracking.result_signs_ns)) + end) +end) diff --git a/tests/ui/conflict/issue_353_spec.lua b/tests/ui/conflict/issue_353_spec.lua index 98ca338e..5b350b0c 100644 --- a/tests/ui/conflict/issue_353_spec.lua +++ b/tests/ui/conflict/issue_353_spec.lua @@ -11,7 +11,7 @@ local h = require('tests.helpers') local diff_module = require('codediff.core.diff') -local merge_alignment = require('codediff.ui.merge_alignment') +local merge_alignment = require('codediff.ui.conflict.merge') describe('Issue #353 regression - 3-way merge auto-merge', function() before_each(function() @@ -134,10 +134,9 @@ describe('Issue #353 regression - 3-way merge auto-merge', function() end) end) --- End-to-end-ish test: stand up a real merge-conflicted git repo, mirror what --- conflict_window.lua does to seed the Result buffer, and assert the Result --- buffer's contents directly. This catches regressions in the wiring between --- conflict_window.lua and compute_auto_merged_result, not just the algorithm. +-- End-to-end-ish test: stand up a real merge-conflicted git repo, mirror how +-- the conflict view seeds the Result buffer, and assert the Result content. +-- This catches wiring regressions, not just auto-merge algorithm regressions. describe('Issue #353 regression - end-to-end with a real git merge', function() local repo @@ -210,7 +209,7 @@ describe('Issue #353 regression - end-to-end with a real git merge', function() repo.git("merge theirs --no-edit") -- Read the three stages directly from the index, just like - -- side_by_side.lua's conflict path does (via git.get_file_content :1/:2/:3). + -- the side-by-side conflict path does (via git.get_file_content :1/:2/:3). local function git_show(spec) local out = repo.git("show " .. spec .. ":deps.lua") local lines = {} @@ -234,9 +233,9 @@ describe('Issue #353 regression - end-to-end with a real git merge', function() local status = repo.git("status --porcelain deps.lua") assert.is_true(status:find("UU", 1, true) ~= nil, 'deps.lua should be in UU state') - -- Reproduce the conflict-window's seed computation. + -- Reproduce the conflict view's seed computation. local diff_opts = { max_computation_time_ms = 2000 } - -- Stage layout matches conflict_window.lua: original=:3 (incoming/theirs), + -- Stage layout matches the conflict view: original=:3 (incoming/theirs), -- modified=:2 (current/ours) in the default conflict_ours_position="right". local base_to_original = diff_module.compute_diff(base_lines, theirs_lines, diff_opts) local base_to_modified = diff_module.compute_diff(base_lines, ours_lines, diff_opts) diff --git a/tests/ui/explorer/native_watcher_spec.lua b/tests/ui/explorer/native_watcher_spec.lua index b1bd5bbe..77d8f928 100644 --- a/tests/ui/explorer/native_watcher_spec.lua +++ b/tests/ui/explorer/native_watcher_spec.lua @@ -1,5 +1,7 @@ describe("explorer native watcher", function() local uv = vim.uv or vim.loop + local config + local original_auto_refresh_enabled local original_new_timer local original_watcher local original_auto_refresh @@ -17,6 +19,9 @@ describe("explorer native watcher", function() local sync_completions before_each(function() + config = require("codediff.config") + original_auto_refresh_enabled = config.options.explorer.auto_refresh + watcher_handlers = nil repository = vim.fn.tempname() vim.fn.mkdir(repository .. "/.git", "p") timers = {} @@ -84,6 +89,7 @@ describe("explorer native watcher", function() package.loaded["codediff.core.watcher"] = original_watcher package.loaded["codediff.ui.auto_refresh"] = original_auto_refresh uv.new_timer = original_new_timer + config.options.explorer.auto_refresh = original_auto_refresh_enabled vim.fn.delete(repository, "rf") end) @@ -147,6 +153,48 @@ describe("explorer native watcher", function() sync_completions[1]() end) + it("serializes direct refreshes when automatic refresh is disabled", function() + config.options.explorer.auto_refresh = false + local explorer = setup() + + assert.equals(0, #timers) + assert.is_nil(watcher_handlers) + + local forced_completed = 0 + local ordinary_completed = 0 + refresh_module.refresh(explorer) + refresh_module.refresh(explorer, function() + forced_completed = forced_completed + 1 + end, true) + refresh_module.refresh(explorer, function() + ordinary_completed = ordinary_completed + 1 + end, false) + + assert.equals(1, refresh_count) + assert.is_false(refresh_forces[1]) + assert.equals(0, forced_completed) + assert.equals(0, ordinary_completed) + + refresh_completions[1]() + assert.equals(1, sync_count) + assert.equals(1, refresh_count) + + sync_completions[1]() + assert.equals(2, refresh_count) + assert.is_true(refresh_forces[2]) + assert.equals(0, forced_completed) + assert.equals(0, ordinary_completed) + + refresh_completions[2]() + assert.equals(2, sync_count) + assert.equals(0, forced_completed) + assert.equals(0, ordinary_completed) + + sync_completions[2]() + assert.equals(1, forced_completed) + assert.equals(1, ordinary_completed) + end) + it("drops a trailing refresh when cleanup happens during mutable sync", function() setup() watcher_handlers.on_ready() diff --git a/tests/ui/merge_alignment_spec.lua b/tests/ui/merge_alignment_spec.lua index 1ba2e489..f1ae2749 100644 --- a/tests/ui/merge_alignment_spec.lua +++ b/tests/ui/merge_alignment_spec.lua @@ -1,7 +1,7 @@ -- Test: Merge Alignment -- Tests the 3-way merge alignment algorithm using the current API -local merge_alignment = require("codediff.ui.merge_alignment") +local merge_alignment = require("codediff.ui.conflict.merge") describe("Merge Alignment", function() -- Helper to create mock diff results diff --git a/tests/ui/view/cycle_hunks_across_files_spec.lua b/tests/ui/view/cycle_hunks_across_files_spec.lua index d6471700..c0c538b1 100644 --- a/tests/ui/view/cycle_hunks_across_files_spec.lua +++ b/tests/ui/view/cycle_hunks_across_files_spec.lua @@ -235,8 +235,8 @@ describe("cycle_hunks_across_files (#161)", function() end) it("ON in INLINE mode: [c on first hunk hops to LAST hunk of previous file", function() - -- Inline mode uses a different render path (inline_view.lua) than the - -- side-by-side path (render.lua). Both must honor pending_cursor_landing. + -- Inline mode uses a different render path (`inline_view/render.lua`) than + -- the side-by-side path (`view/render.lua`). Both honor pending_cursor_landing. local cfg = require("codediff.config") cfg.options = vim.deepcopy(cfg.defaults) cfg.options.diff.layout = "inline" diff --git a/tests/ui/view/inline_explorer_spec.lua b/tests/ui/view/inline_explorer_spec.lua index 4c9cde1c..17777ea6 100644 --- a/tests/ui/view/inline_explorer_spec.lua +++ b/tests/ui/view/inline_explorer_spec.lua @@ -388,7 +388,7 @@ describe("Inline diff with explorer", function() -- on every call, so each refresh tick swapped a fresh scratch buffer into -- the modified window and reset the cursor. The fix replaces that with -- bufadd(virtual_file.create_url(...)), which returns a stable bufnr keyed - -- by (git_root, revision, path) — same pattern as side_by_side.lua. + -- by (git_root, revision, path) — same pattern as the side-by-side view. it("Repeated show_single_file for staged virtual file keeps bufnr stable (#401)", function() if vim.fn.executable("git") ~= 1 then pending("git not available")