Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 49 additions & 13 deletions lua/codediff/ui/lifecycle/cleanup.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
50 changes: 42 additions & 8 deletions lua/codediff/ui/view/side_by_side.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
123 changes: 123 additions & 0 deletions tests/ui/explorer/explorer_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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("<CR>", 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("<CR>", 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
Expand Down
Loading