From e98be80b6722c918186a43ccd9fe8c890859b0ae Mon Sep 17 00:00:00 2001 From: Orestis Floros Date: Mon, 27 Jul 2026 12:29:35 +0200 Subject: [PATCH] fix: reopen closed diff panes from explorer Keep explorer and history sessions active when users manually close one or both side-by-side diff panes, provided the panel remains usable. Recreate missing panes on the next file selection while preserving panel placement and the configured original side. Count marked diff windows in the owning tab to avoid deferred WinClosed and BufEnter cleanup races across tabs. --- lua/codediff/ui/lifecycle/cleanup.lua | 62 ++++++++++--- lua/codediff/ui/view/side_by_side.lua | 50 +++++++++-- tests/ui/explorer/explorer_spec.lua | 123 ++++++++++++++++++++++++++ 3 files changed, 214 insertions(+), 21 deletions(-) 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