diff --git a/lua/codediff/ui/lifecycle/cleanup.lua b/lua/codediff/ui/lifecycle/cleanup.lua index 4a31a07d..412be612 100644 --- a/lua/codediff/ui/lifecycle/cleanup.lua +++ b/lua/codediff/ui/lifecycle/cleanup.lua @@ -130,11 +130,14 @@ local function cleanup_diff(tabpage) active_diffs[tabpage] = nil end --- Count windows in current tabpage that have diff markers -local function count_diff_windows() +-- Count windows in a tabpage that have diff markers +local function count_diff_windows(tabpage) + if not tabpage or not vim.api.nvim_tabpage_is_valid(tabpage) then + return 0 + end + local count = 0 - for i = 1, vim.fn.winnr("$") do - local win = vim.fn.win_getid(i) + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(tabpage)) do if vim.w[win].codediff_restore then count = count + 1 end @@ -147,6 +150,44 @@ local function should_cleanup(winid) return vim.w[winid].codediff_restore and vim.api.nvim_win_is_valid(winid) end +local function has_usable_panel(tabpage, diff) + if diff.mode ~= "explorer" and diff.mode ~= "history" then + return false + end + + local panel = diff.explorer + if + not panel + or panel.is_hidden + or type(panel.on_file_select) ~= "function" + or not panel.winid + or not vim.api.nvim_win_is_valid(panel.winid) + or not panel.bufnr + or not vim.api.nvim_buf_is_valid(panel.bufnr) + then + return false + end + + return vim.api.nvim_win_get_tabpage(panel.winid) == tabpage and vim.api.nvim_win_get_buf(panel.winid) == panel.bufnr +end + +local function cleanup_threshold(tabpage, diff) + local has_result = diff.result_win and vim.api.nvim_win_is_valid(diff.result_win) + + -- A visible panel is still an entry point into a side-by-side session. Keep + -- its state even with no diff panes so the next selection can rebuild them. + if diff.layout ~= "inline" and not has_result and has_usable_panel(tabpage, diff) then + return -1 + end + + local is_single_window = diff.single_pane == true or diff.layout == "inline" + if is_single_window then + return 0 + end + + return 1 +end + -- Setup autocmds for automatic cleanup function M.setup_autocmds() -- When a window is closed, check if we should cleanup the diff @@ -164,11 +205,8 @@ function M.setup_autocmds() local active_diffs = session.get_active_diffs() for tabpage, diff in pairs(active_diffs) do if diff.original_win == closed_win or diff.modified_win == closed_win then - -- single_pane/inline mode: we expect only 1 diff window - local is_single_window = diff.single_pane == true or diff.layout == "inline" - local diff_win_count = count_diff_windows() - local threshold = is_single_window and 0 or 1 - if diff_win_count <= threshold then + local diff_win_count = count_diff_windows(tabpage) + if diff_win_count <= cleanup_threshold(tabpage, diff) then cleanup_diff(tabpage) end break @@ -217,10 +255,8 @@ function M.setup_autocmds() end local diff = session.get_active_diffs()[tabpage] if diff then - local diff_win_count = count_diff_windows() - local is_single_window = diff.single_pane == true or diff.layout == "inline" - local threshold = is_single_window and 0 or 1 - if diff_win_count <= threshold then + local diff_win_count = count_diff_windows(tabpage) + if diff_win_count <= cleanup_threshold(tabpage, diff) then cleanup_diff(tabpage) end end diff --git a/lua/codediff/ui/view/side_by_side.lua b/lua/codediff/ui/view/side_by_side.lua index d9b3de1a..1168212e 100644 --- a/lua/codediff/ui/view/side_by_side.lua +++ b/lua/codediff/ui/view/side_by_side.lua @@ -412,8 +412,41 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) 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 + local original_win_valid = original_win and vim.api.nvim_win_is_valid(original_win) + local modified_win_valid = modified_win and vim.api.nvim_win_is_valid(modified_win) + + -- If the panel is the only remaining window, recreate the first diff pane + -- beside/above it. The normal split path below can then recreate the other + -- pane while preserving original_position. + if not original_win_valid and not modified_win_valid then + local panel_state = session.explorer + local panel_win = panel_state and panel_state.winid + if not panel_win or panel_state.is_hidden or not vim.api.nvim_win_is_valid(panel_win) or vim.api.nvim_win_get_tabpage(panel_win) ~= tabpage then + return false + end + + vim.api.nvim_set_current_win(panel_win) + + local panel_config = session.mode == "history" and (config.options.history or {}) or (config.options.explorer or {}) + local panel_position = panel_config.position or (session.mode == "history" and "bottom" or "left") + local content_split = panel_position == "bottom" and "above" or "right" + local scratch = vim.api.nvim_create_buf(false, true) + vim.bo[scratch].buftype = "nofile" + vim.bo[scratch].bufhidden = "wipe" + + local ok, recreated_win = pcall(vim.api.nvim_open_win, scratch, true, { + split = content_split, + win = -1, + }) + if not ok then + pcall(vim.api.nvim_buf_delete, scratch, { force = true }) + return false + end + + original_win = recreated_win + original_win_valid = true + session.original_win = original_win + vim.w[original_win].codediff_restore = 1 end -- Disable auto-refresh temporarily @@ -434,19 +467,20 @@ function M.update(tabpage, session_config, auto_scroll_to_first_hunk) lifecycle.set_result(tabpage, nil, nil) end - -- Restore second window if returning from single-pane mode - if session.single_pane then + -- Restore the second window when returning from single-pane mode or when + -- either side was manually closed. + if session.single_pane or not original_win_valid or not modified_win_valid 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 + if not original_win_valid then + -- Recreate original on the configured side 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 + elseif not modified_win_valid then + -- Recreate modified on the configured side of original vim.api.nvim_set_current_win(original_win) vim.cmd(split_cmd) modified_win = vim.api.nvim_get_current_win() diff --git a/tests/ui/explorer/explorer_spec.lua b/tests/ui/explorer/explorer_spec.lua index 70967ca4..440b95cf 100644 --- a/tests/ui/explorer/explorer_spec.lua +++ b/tests/ui/explorer/explorer_spec.lua @@ -321,6 +321,129 @@ describe("Explorer Mode", function() assert.is_true(has_explorer, "Should have explorer window") end) + it("Recreates manually closed diff windows when another file is selected", function() + local lifecycle = require("codediff.ui.lifecycle") + vim.wait(200) + lifecycle.cleanup_all() + + vim.fn.writefile({ "modified nested" }, temp_dir .. "/nested/deep.txt") + vim.cmd("edit " .. temp_dir .. "/file1.txt") + vim.cmd("CodeDiff") + + local tabpage + local session + local explorer + local ready = vim.wait(6000, function() + for _, tp in ipairs(vim.api.nvim_list_tabpages()) do + local candidate = lifecycle.get_session(tp) + local candidate_explorer = candidate and candidate.explorer + if + candidate_explorer + and candidate_explorer.current_file_path ~= nil + and candidate.original_win + and candidate.modified_win + and vim.api.nvim_win_is_valid(candidate.original_win) + and vim.api.nvim_win_is_valid(candidate.modified_win) + then + tabpage = tp + session = candidate + explorer = candidate_explorer + return true + end + end + return false + end, 20) + assert.is_true(ready, "Explorer diff should be ready") + + local target_line + local target_path + for line = 1, vim.api.nvim_buf_line_count(explorer.bufnr) do + local node = explorer.tree:get_node(line) + if node and node.data and node.data.path and node.data.path ~= explorer.current_file_path and node.data.status == "M" then + target_line = line + target_path = node.data.path + break + end + end + assert.is_not_nil(target_line, "Should find another modified file") + + local closed_win = session.modified_win + vim.api.nvim_set_current_win(closed_win) + vim.cmd("close") + vim.wait(200) + + assert.is_false(vim.api.nvim_win_is_valid(closed_win), "Modified window should be closed") + assert.is_not_nil(lifecycle.get_session(tabpage), "Explorer session should survive one closed diff window") + assert.is_true(vim.api.nvim_win_is_valid(explorer.winid), "Explorer window should remain open") + + vim.api.nvim_set_current_win(explorer.winid) + vim.api.nvim_win_set_cursor(explorer.winid, { target_line, 0 }) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "tx", false) + + local repaired = vim.wait(6000, function() + session = lifecycle.get_session(tabpage) + if + not session + or not session.original_win + or not session.modified_win + or not vim.api.nvim_win_is_valid(session.original_win) + or not vim.api.nvim_win_is_valid(session.modified_win) + then + return false + end + return session.modified.relative == target_path + end, 20) + + assert.is_true(repaired, "Selecting another file should recreate both diff windows") + assert.is_true(vim.api.nvim_win_is_valid(explorer.winid), "Explorer should be preserved after repair") + assert.equals(3, #vim.api.nvim_tabpage_list_wins(tabpage), "Explorer and two diff windows should be open") + + local next_line + local next_path + for line = 1, vim.api.nvim_buf_line_count(explorer.bufnr) do + local node = explorer.tree:get_node(line) + if node and node.data and node.data.path and node.data.path ~= explorer.current_file_path and node.data.status == "M" then + next_line = line + next_path = node.data.path + break + end + end + assert.is_not_nil(next_line, "Should find a file to select after both panes close") + + local closed_original = session.original_win + local closed_modified = session.modified_win + vim.api.nvim_win_close(closed_original, false) + vim.api.nvim_win_close(closed_modified, false) + vim.wait(200) + + assert.is_false(vim.api.nvim_win_is_valid(closed_original), "Original window should be closed") + assert.is_false(vim.api.nvim_win_is_valid(closed_modified), "Modified window should be closed") + assert.is_not_nil(lifecycle.get_session(tabpage), "Explorer session should survive both closed diff windows") + assert.is_true(vim.api.nvim_win_is_valid(explorer.winid), "Explorer should be the remaining window") + + vim.api.nvim_set_current_win(explorer.winid) + vim.api.nvim_win_set_cursor(explorer.winid, { next_line, 0 }) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "tx", false) + + local both_repaired = vim.wait(6000, function() + session = lifecycle.get_session(tabpage) + if + not session + or not session.original_win + or not session.modified_win + or not vim.api.nvim_win_is_valid(session.original_win) + or not vim.api.nvim_win_is_valid(session.modified_win) + then + return false + end + return session.modified.relative == next_path + end, 20) + + assert.is_true(both_repaired, "Selecting another file should recreate both closed diff windows") + assert.is_true(vim.api.nvim_win_is_valid(explorer.winid), "Explorer should remain open after both panes are repaired") + assert.equals(3, #vim.api.nvim_tabpage_list_wins(tabpage), "Explorer and both recreated diff windows should be open") + end) + -- Test 4: Window widths are properly distributed it("Distributes window widths correctly", function() vim.o.columns = 160 -- Set known terminal width