diff --git a/VERSION b/VERSION index cf869073..ef0f38ab 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.18.0 +2.19.0 diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index bff256b1..45b9ec06 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -96,6 +96,11 @@ M.defaults = { accept_current = "co", -- Accept current (ours/right) change accept_both = "cb", -- Accept both changes (incoming first) discard = "cx", -- Discard both, keep base + -- Accept all (whole file) - uppercase versions like diffview + accept_all_incoming = "cT", -- Accept ALL incoming changes + accept_all_current = "cO", -- Accept ALL current changes + accept_all_both = "cB", -- Accept ALL both changes + discard_all = "cX", -- Discard ALL, reset to base next_conflict = "]x", -- Jump to next conflict prev_conflict = "[x", -- Jump to previous conflict -- Vimdiff-style numbered diffget (from result buffer) diff --git a/lua/codediff/ui/conflict/actions.lua b/lua/codediff/ui/conflict/actions.lua index c5cb9256..f3cc247e 100644 --- a/lua/codediff/ui/conflict/actions.lua +++ b/lua/codediff/ui/conflict/actions.lua @@ -500,4 +500,216 @@ function M.discard(tabpage) 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 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] + -- 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, base_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 6094115b..eb973641 100644 --- a/lua/codediff/ui/conflict/init.lua +++ b/lua/codediff/ui/conflict/init.lua @@ -35,6 +35,10 @@ 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 diff --git a/lua/codediff/ui/conflict/keymaps.lua b/lua/codediff/ui/conflict/keymaps.lua index 723d8f8c..10eee27a 100644 --- a/lua/codediff/ui/conflict/keymaps.lua +++ b/lua/codediff/ui/conflict/keymaps.lua @@ -96,6 +96,34 @@ function M.setup_keymaps(tabpage) ) end + -- Accept ALL incoming + if keymaps.accept_all_incoming then + vim.keymap.set("n", keymaps.accept_all_incoming, function() + actions.accept_all_incoming(tabpage) + end, vim.tbl_extend("force", base_opts, { buffer = bufnr, desc = "Accept ALL incoming changes" })) + end + + -- Accept ALL current + if keymaps.accept_all_current then + vim.keymap.set("n", keymaps.accept_all_current, function() + actions.accept_all_current(tabpage) + end, vim.tbl_extend("force", base_opts, { buffer = bufnr, desc = "Accept ALL current changes" })) + end + + -- Accept ALL both + if keymaps.accept_all_both then + vim.keymap.set("n", keymaps.accept_all_both, function() + actions.accept_all_both(tabpage) + end, vim.tbl_extend("force", base_opts, { buffer = bufnr, desc = "Accept ALL both changes" })) + end + + -- Discard ALL + if keymaps.discard_all then + vim.keymap.set("n", keymaps.discard_all, function() + actions.discard_all(tabpage) + end, vim.tbl_extend("force", base_opts, { buffer = bufnr, desc = "Discard ALL, reset to base" })) + end + -- Navigation if keymaps.next_conflict then vim.keymap.set("n", keymaps.next_conflict, function() diff --git a/tests/render/conflict_accept_all_spec.lua b/tests/render/conflict_accept_all_spec.lua new file mode 100644 index 00000000..58d4d139 --- /dev/null +++ b/tests/render/conflict_accept_all_spec.lua @@ -0,0 +1,272 @@ +local conflict = require('codediff.ui.conflict') +local lifecycle = require('codediff.ui.lifecycle') +local assert = require('luassert') + +describe("Conflict Accept All Actions", function() + local tabpage + local result_bufnr + local original_bufnr + local modified_bufnr + local conflict_blocks + + before_each(function() + tabpage = 1 + + -- Create result buffer with 3 conflict regions + -- Lines 3-4, 7-8, 11-12 are conflicts + result_bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(result_bufnr, 0, -1, false, { + "Line 1", -- 1 + "Line 2", -- 2 + "Base Conflict 1a", -- 3 (conflict 1) + "Base Conflict 1b", -- 4 + "Line 5", -- 5 + "Line 6", -- 6 + "Base Conflict 2a", -- 7 (conflict 2) + "Base Conflict 2b", -- 8 + "Line 9", -- 9 + "Line 10", -- 10 + "Base Conflict 3a", -- 11 (conflict 3) + "Base Conflict 3b", -- 12 + "Line 13", -- 13 + }) + + -- Create incoming (left/theirs) buffer + original_bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(original_bufnr, "AcceptAllOriginal") + vim.api.nvim_buf_set_lines(original_bufnr, 0, -1, false, { + "Incoming 1a", -- 1 + "Incoming 1b", -- 2 + "Incoming 2a", -- 3 + "Incoming 2b", -- 4 + "Incoming 3a", -- 5 + "Incoming 3b", -- 6 + }) + + -- Create current (right/ours) buffer + modified_bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(modified_bufnr, "AcceptAllModified") + vim.api.nvim_buf_set_lines(modified_bufnr, 0, -1, false, { + "Current 1a", -- 1 + "Current 1b", -- 2 + "Current 2a", -- 3 + "Current 2b", -- 4 + "Current 3a", -- 5 + "Current 3b", -- 6 + }) + + -- Define 3 conflict blocks + conflict_blocks = { + { + base_range = { start_line = 3, end_line = 5 }, + output1_range = { start_line = 1, end_line = 3 }, + output2_range = { start_line = 1, end_line = 3 }, + }, + { + base_range = { start_line = 7, end_line = 9 }, + output1_range = { start_line = 3, end_line = 5 }, + output2_range = { start_line = 3, end_line = 5 }, + }, + { + base_range = { start_line = 11, end_line = 13 }, + output1_range = { start_line = 5, end_line = 7 }, + output2_range = { start_line = 5, end_line = 7 }, + }, + } + + local session = { + result_bufnr = result_bufnr, + conflict_blocks = conflict_blocks, + original_bufnr = original_bufnr, + modified_bufnr = modified_bufnr, + result_base_lines = { + "Line 1", "Line 2", + "Base Conflict 1a", "Base Conflict 1b", + "Line 5", "Line 6", + "Base Conflict 2a", "Base Conflict 2b", + "Line 9", "Line 10", + "Base Conflict 3a", "Base Conflict 3b", + "Line 13", + } + } + + lifecycle.get_session = function(tp) + if tp == tabpage then return session end + return nil + end + + conflict.initialize_tracking(result_bufnr, conflict_blocks) + end) + + after_each(function() + for _, bufnr in ipairs({ result_bufnr, original_bufnr, modified_bufnr }) do + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_delete(bufnr, { force = true }) + end + end + end) + + describe("accept_all_incoming", function() + it("should replace all conflict regions with incoming content", function() + local success = conflict.accept_all_incoming(tabpage) + assert.is_true(success) + + local lines = vim.api.nvim_buf_get_lines(result_bufnr, 0, -1, false) + + -- Non-conflict lines should be unchanged + assert.are.equal("Line 1", lines[1]) + assert.are.equal("Line 2", lines[2]) + assert.are.equal("Line 5", lines[5]) + assert.are.equal("Line 6", lines[6]) + assert.are.equal("Line 9", lines[9]) + assert.are.equal("Line 10", lines[10]) + assert.are.equal("Line 13", lines[13]) + + -- Conflict regions should have incoming content + assert.are.equal("Incoming 1a", lines[3]) + assert.are.equal("Incoming 1b", lines[4]) + assert.are.equal("Incoming 2a", lines[7]) + assert.are.equal("Incoming 2b", lines[8]) + assert.are.equal("Incoming 3a", lines[11]) + assert.are.equal("Incoming 3b", lines[12]) + end) + + it("should return false when no session exists", function() + lifecycle.get_session = function() return nil end + local success = conflict.accept_all_incoming(tabpage) + assert.is_false(success) + end) + + it("should return false when no conflicts exist", function() + local session = lifecycle.get_session(tabpage) + session.conflict_blocks = {} + local success = conflict.accept_all_incoming(tabpage) + assert.is_false(success) + end) + + it("should skip already resolved conflicts", function() + -- Resolve conflict 1 manually first + vim.api.nvim_set_current_buf(original_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + conflict.accept_incoming(tabpage) + + -- Now accept_all should only resolve the remaining 2 + local success = conflict.accept_all_incoming(tabpage) + assert.is_true(success) + + local lines = vim.api.nvim_buf_get_lines(result_bufnr, 0, -1, false) + -- All 3 should now be incoming + assert.are.equal("Incoming 1a", lines[3]) + assert.are.equal("Incoming 1b", lines[4]) + assert.are.equal("Incoming 2a", lines[7]) + assert.are.equal("Incoming 2b", lines[8]) + assert.are.equal("Incoming 3a", lines[11]) + assert.are.equal("Incoming 3b", lines[12]) + end) + end) + + describe("accept_all_current", function() + it("should replace all conflict regions with current content", function() + local success = conflict.accept_all_current(tabpage) + assert.is_true(success) + + local lines = vim.api.nvim_buf_get_lines(result_bufnr, 0, -1, false) + + -- Non-conflict lines unchanged + assert.are.equal("Line 1", lines[1]) + assert.are.equal("Line 2", lines[2]) + assert.are.equal("Line 13", lines[13]) + + -- Conflict regions should have current content + assert.are.equal("Current 1a", lines[3]) + assert.are.equal("Current 1b", lines[4]) + assert.are.equal("Current 2a", lines[7]) + assert.are.equal("Current 2b", lines[8]) + assert.are.equal("Current 3a", lines[11]) + assert.are.equal("Current 3b", lines[12]) + end) + end) + + describe("accept_all_both", function() + it("should combine both sides for all conflicts (incoming first by default)", function() + local success = conflict.accept_all_both(tabpage) + assert.is_true(success) + + local lines = vim.api.nvim_buf_get_lines(result_bufnr, 0, -1, false) + + -- Non-conflict lines unchanged + assert.are.equal("Line 1", lines[1]) + assert.are.equal("Line 2", lines[2]) + + -- Conflict 1: incoming then current (4 lines instead of 2) + assert.are.equal("Incoming 1a", lines[3]) + assert.are.equal("Incoming 1b", lines[4]) + assert.are.equal("Current 1a", lines[5]) + assert.are.equal("Current 1b", lines[6]) + end) + + it("should respect first_input=2 for current-first ordering", function() + local success = conflict.accept_all_both(tabpage, 2) + assert.is_true(success) + + local lines = vim.api.nvim_buf_get_lines(result_bufnr, 0, -1, false) + + -- Conflict 1: current then incoming + assert.are.equal("Current 1a", lines[3]) + assert.are.equal("Current 1b", lines[4]) + assert.are.equal("Incoming 1a", lines[5]) + assert.are.equal("Incoming 1b", lines[6]) + end) + end) + + describe("discard_all", function() + it("should reset all conflicts to base content", function() + -- First resolve all conflicts + conflict.accept_all_incoming(tabpage) + + -- Then discard all + local success = conflict.discard_all(tabpage) + assert.is_true(success) + + local lines = vim.api.nvim_buf_get_lines(result_bufnr, 0, -1, false) + + -- Everything should be back to original base + assert.are.equal("Base Conflict 1a", lines[3]) + assert.are.equal("Base Conflict 1b", lines[4]) + assert.are.equal("Base Conflict 2a", lines[7]) + assert.are.equal("Base Conflict 2b", lines[8]) + assert.are.equal("Base Conflict 3a", lines[11]) + assert.are.equal("Base Conflict 3b", lines[12]) + end) + + it("should return false when no session exists", function() + lifecycle.get_session = function() return nil end + local success = conflict.discard_all(tabpage) + assert.is_false(success) + end) + end) + + describe("atomic undo", function() + it("should undo all accept_all_incoming changes with a single undo", function() + -- Set result buffer as current and establish a proper undo break point + vim.api.nvim_set_current_buf(result_bufnr) + vim.cmd("let &undolevels = &undolevels") + + conflict.accept_all_incoming(tabpage) + + -- Verify changes applied + local lines = vim.api.nvim_buf_get_lines(result_bufnr, 0, -1, false) + assert.are.equal("Incoming 1a", lines[3]) + assert.are.equal("Incoming 2a", lines[7]) + assert.are.equal("Incoming 3a", lines[11]) + + -- Single undo should revert all changes + vim.cmd("undo") + + lines = vim.api.nvim_buf_get_lines(result_bufnr, 0, -1, false) + assert.are.equal("Base Conflict 1a", lines[3]) + assert.are.equal("Base Conflict 2a", lines[7]) + assert.are.equal("Base Conflict 3a", lines[11]) + end) + end) +end)