diff --git a/fixtures/issue-262/init.lua b/fixtures/issue-262/init.lua new file mode 100644 index 00000000..862fb7bb --- /dev/null +++ b/fixtures/issue-262/init.lua @@ -0,0 +1,170 @@ +-- Fixture for issue #262: +-- "diff: open_in_new_tab can strand a tab if setup errors before the diff +-- state is registered" +-- https://github.com/coder/claudecode.nvim/issues/262 +-- +-- This fixture configures diff_opts.open_in_new_tab = true and provides a single +-- trigger (:ReproStrandTab / x) that exercises the realistic failure: a +-- user BufReadPre autocmd throws while the original file is :edit-ed during diff +-- setup. Because the error happens AFTER display_terminal_in_new_tab() ran +-- `:tabnew` but BEFORE the diff state is registered, the post-pcall error handler +-- cannot close the new tab -> it is stranded, and focus is left on it. +-- +-- Usage (from repo root): +-- source fixtures/nvim-aliases.sh && vv issue-262 +-- then press x (or run :ReproStrandTab). Watch the tabline jump from one +-- tab to two; the extra empty tab is the stranded one (#262). + +local config_dir = vim.fn.stdpath("config") +local repo_root = vim.fn.fnamemodify(config_dir, ":h:h") +vim.opt.rtp:prepend(repo_root) + +vim.g.mapleader = " " +vim.g.maplocalleader = "\\" + +-- Always show the tabline so the stranded tab is visible, with an explicit, +-- unambiguous label per tab (number + active marker + buffer name). +vim.o.showtabline = 2 +vim.o.laststatus = 2 +function _G.Repro262Tabline() + local s = {} + for i = 1, vim.fn.tabpagenr("$") do + local active = (i == vim.fn.tabpagenr()) + local winnr = vim.fn.tabpagewinnr(i) + local buflist = vim.fn.tabpagebuflist(i) + local bufname = vim.fn.bufname(buflist[winnr]) + local label = (bufname == "" and "[No Name]" or vim.fn.fnamemodify(bufname, ":t")) + s[#s + 1] = (active and "%#TabLineSel#" or "%#TabLine#") + s[#s + 1] = (" TAB %d%s: %s "):format(i, active and " (active)" or "", label) + end + s[#s + 1] = "%#TabLineFill#" + return table.concat(s) +end +vim.o.tabline = "%!v:lua.Repro262Tabline()" + +local ok, claudecode = pcall(require, "claudecode") +assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode)) + +claudecode.setup({ + auto_start = false, + log_level = "info", + terminal = { + provider = "native", + auto_close = false, + }, + diff_opts = { + layout = "vertical", + open_in_new_tab = true, -- the path under test (#262) + keep_terminal_focus = false, + on_new_file_reject = "keep_empty", + }, +}) + +-- A normal editor buffer in the first tab so the layout looks like real usage. +local banner = { + "claudecode.nvim -- issue #262 reproduction fixture", + "", + "diff_opts.open_in_new_tab = true", + "", + "Press x (space then x) or run :ReproStrandTab", + "", + "Expected on UNFIXED code: the tabline jumps from 1 tab to 2,", + "and focus is left on the new, EMPTY tab -- that extra tab is", + "stranded because diff setup errored before the diff state was", + "registered, so neither cleanup path can close it.", +} +vim.api.nvim_buf_set_lines(0, 0, -1, false, banner) +vim.bo.modifiable = false +vim.bo.modified = false + +---Trigger the realistic #262 failure: a throwing BufReadPre autocmd fires while +---diff setup runs `:edit `, after the new tab was already created. +local function repro_strand_tab() + -- Assert the diff module config (defensive; claudecode.setup already did this). + local diff = require("claudecode.diff") + diff.setup({ + diff_opts = { layout = "vertical", open_in_new_tab = true, on_new_file_reject = "keep_empty" }, + terminal = {}, + }) + + local before = vim.fn.tabpagenr("$") + + -- A fresh on-disk file that is NOT already loaded, so `:edit` reads it and + -- fires BufReadPre (where our autocmd throws). + local old_file = vim.fn.tempname() .. "_issue262.md" + local fh = io.open(old_file, "w") + fh:write("# original\n\nline one\nline two\n") + fh:close() + + local grp = vim.api.nvim_create_augroup("repro262", { clear = true }) + vim.api.nvim_create_autocmd("BufReadPre", { + group = grp, + pattern = old_file, + callback = function() + error("simulated BufReadPre failure (#262)") + end, + }) + + pcall(function() + diff._setup_blocking_diff({ + old_file_path = old_file, + new_file_path = old_file, + new_file_contents = "# proposed by Claude\n\nNEW line one\nline two\n", + tab_name = "✻ [Claude Code] issue262.md ⧉", + }, function() end) + end) + + pcall(vim.api.nvim_del_augroup_by_id, grp) + os.remove(old_file) + + local after = vim.fn.tabpagenr("$") + -- Keep the message to ONE short line so it doesn't trip the hit-enter prompt. + vim.api.nvim_echo({ + { + ("repro262: tabs %d -> %d%s"):format(before, after, after > before and " <<< STRANDED TAB" or " (no leak)"), + after > before and "ErrorMsg" or "MoreMsg", + }, + }, false, {}) +end + +vim.api.nvim_create_user_command("ReproStrandTab", repro_strand_tab, { desc = "Repro #262 stranded tab" }) +vim.keymap.set("n", "x", repro_strand_tab, { desc = "Repro #262 stranded tab" }) + +-- Success-path probe: open a real diff in a new tab with NO injected error. The +-- fix must NOT close this tab (the error-branch tabclose should only fire on +-- failure). Used during /verify to confirm the change didn't over-close. +vim.api.nvim_create_user_command("ReproOpenDiffOk", function() + local diff = require("claudecode.diff") + diff.setup({ + diff_opts = { layout = "vertical", open_in_new_tab = true, on_new_file_reject = "keep_empty" }, + terminal = {}, + }) + local before = vim.fn.tabpagenr("$") + local old_file = vim.fn.tempname() .. "_ok262.md" + local fh = io.open(old_file, "w") + fh:write("# original\n\nline one\nline two\n") + fh:close() + local ok_setup = pcall(function() + diff._setup_blocking_diff({ + old_file_path = old_file, + new_file_path = old_file, + new_file_contents = "# proposed by Claude\n\nNEW line one\nline two\n", + tab_name = "✻ [Claude Code] ok262.md ⧉", + }, function() end) + end) + os.remove(old_file) + local after = vim.fn.tabpagenr("$") + vim.api.nvim_echo({ + { + ("ReproOpenDiffOk: ok=%s tabs %d -> %d"):format(tostring(ok_setup), before, after), + ok_setup and "MoreMsg" or "ErrorMsg", + }, + }, false, {}) +end, { desc = "Open a successful diff in a new tab (#262 success-path probe)" }) + +vim.api.nvim_create_user_command("ReproReset", function() + require("claudecode.diff")._cleanup_all_active_diffs("repro reset") + vim.cmd("silent! tabonly!") + vim.cmd("silent! only!") + vim.api.nvim_echo({ { ("ReproReset: tabs=%d"):format(vim.fn.tabpagenr("$")), "MoreMsg" } }, false, {}) +end, { desc = "Reset repro layout to a single tab" }) diff --git a/lua/claudecode/diff.lua b/lua/claudecode/diff.lua index 028ed146..4e234e8e 100644 --- a/lua/claudecode/diff.lua +++ b/lua/claudecode/diff.lua @@ -1143,6 +1143,11 @@ function M._setup_blocking_diff(params, resolution_callback) -- (the state-based cleanup is gated on a registered diff). Issue #231. local fallback_window = nil local new_buffer = nil + -- Same rationale for the open_in_new_tab path: display_terminal_in_new_tab() runs `:tabnew` + -- early, so a failure before registration would strand that tab. Hoist its handle (and the tab + -- we came from, to refocus) so the error handler can close it and switch back. Issue #262. + local new_tab_handle = nil + local original_tab_handle = nil -- Wrap the setup in error handling to ensure cleanup on failure local setup_success, setup_error = pcall(function() @@ -1165,11 +1170,15 @@ function M._setup_blocking_diff(params, resolution_callback) local terminal_win_in_new_tab = nil local existing_buffer = nil local target_window = nil - -- Track new tab handle and original terminal visibility for robust cleanup - local new_tab_handle = nil + -- new_tab_handle is hoisted above the pcall (issue #262) so the error handler can reach it; + -- only the original-terminal visibility flag is local here. local had_terminal_in_original = false if config and config.diff_opts and config.diff_opts.open_in_new_tab then + -- Capture the tab we're leaving BEFORE display_terminal_in_new_tab() runs `:tabnew`, so the + -- error handler can still refocus it if that helper throws partway (Lua then leaves + -- new_tab_handle unassigned -- see the error branch below). Issue #262. + original_tab_handle = vim.api.nvim_get_current_tabpage() original_tab_number, terminal_win_in_new_tab, had_terminal_in_original, new_tab_handle = display_terminal_in_new_tab() created_new_tab = true @@ -1320,6 +1329,29 @@ function M._setup_blocking_diff(params, resolution_callback) if new_buffer and vim.api.nvim_buf_is_valid(new_buffer) then pcall(vim.api.nvim_buf_delete, new_buffer, { force = true }) end + -- Close the tab opened by display_terminal_in_new_tab() before registration (issue #262). + -- That helper runs `:tabnew` (switching to the new tab) and the tab holds no user content. + -- Prefer the returned handle; if the helper itself threw after `:tabnew`, Lua never assigned + -- new_tab_handle, so fall back to the current tab (which is still that new tab) when we can + -- tell it apart from the original. Refocus the original tab afterwards. + local stranded_tab = new_tab_handle + if not (stranded_tab and vim.api.nvim_tabpage_is_valid(stranded_tab)) then + local current_tab = vim.api.nvim_get_current_tabpage() + if + original_tab_handle + and vim.api.nvim_tabpage_is_valid(original_tab_handle) + and current_tab ~= original_tab_handle + then + stranded_tab = current_tab + end + end + if stranded_tab and vim.api.nvim_tabpage_is_valid(stranded_tab) and stranded_tab ~= original_tab_handle then + pcall(vim.api.nvim_set_current_tabpage, stranded_tab) + pcall(vim.cmd, "tabclose") + if original_tab_handle and vim.api.nvim_tabpage_is_valid(original_tab_handle) then + pcall(vim.api.nvim_set_current_tabpage, original_tab_handle) + end + end end -- Re-throw the error for MCP compliance diff --git a/scripts/repro_issue_262.lua b/scripts/repro_issue_262.lua new file mode 100644 index 00000000..7ca10d5f --- /dev/null +++ b/scripts/repro_issue_262.lua @@ -0,0 +1,240 @@ +-- Reproduction / verification for issue #262: +-- "diff: open_in_new_tab can strand a tab if setup errors before the diff +-- state is registered" +-- https://github.com/coder/claudecode.nvim/issues/262 +-- +-- The bug: with diff_opts.open_in_new_tab = true, M._setup_blocking_diff calls +-- display_terminal_in_new_tab() EARLY (it runs `:tabnew`). If setup then throws +-- before M._register_diff_state runs, the post-pcall error handler cannot close +-- that tab: +-- * the state-based cleanup is gated on a registered diff (none exists yet); +-- * the pre-registration `else` branch (added in #260) only closes +-- `fallback_window` and deletes `new_buffer`; +-- * `new_tab_handle` is declared INSIDE the pcall closure, so the error +-- handler can't even reach it. +-- Result: one stranded extra tab per failed setup, and the original tab is not +-- refocused. +-- +-- This script drives the REAL diff.lua against the open_in_new_tab path, with no +-- WebSocket/Claude CLI needed. It exercises the exact code path the openDiff MCP +-- tool uses (M._setup_blocking_diff), so it both reproduces the bug (on unfixed +-- code) and will verify the fix. +-- +-- Run from the repo root: +-- nvim --headless -u NONE -l scripts/repro_issue_262.lua +-- +-- Exit code: 1 if ANY scenario strands a tab (#262 reproduced), 0 if all clean. +-- The detailed verdict is printed to stdout either way. + +local script_path = debug.getinfo(1, "S").source:sub(2) +local repo_root = vim.fn.fnamemodify(script_path, ":h:h") +vim.opt.rtp:prepend(repo_root) + +local function out(msg) + io.stdout:write(msg .. "\n") +end + +local diff = require("claudecode.diff") + +local function count_tabs() + return vim.fn.tabpagenr("$") +end + +-- Collapse to a single clean tab/window so each scenario starts from tabs == 1. +local function reset_layout() + vim.cmd("silent! tabonly!") + vim.cmd("silent! only!") + vim.cmd("silent! enew!") + diff._cleanup_all_active_diffs("repro reset") +end + +-- Write a throwaway "existing" old file (so is_new_file = false and the +-- existing-file path that runs `:edit old_file_path` is exercised). +local function make_old_file(tag) + local p = vim.fn.tempname() .. "_" .. tag .. ".md" + local fh = io.open(p, "w") + fh:write("# original\n\nline one\nline two\n") + fh:close() + return p +end + +diff.setup({ + diff_opts = { + layout = "vertical", + open_in_new_tab = true, -- the path under test + keep_terminal_focus = false, + on_new_file_reject = "keep_empty", + }, + terminal = {}, +}) + +---@class ScenarioResult +---@field name string +---@field stranded boolean +---@field refocused boolean +---@field before number +---@field after number + +---@param name string +---@param run fun() -- arranges the forced failure and calls _setup_blocking_diff +---@return ScenarioResult +local function run_scenario(name, run) + reset_layout() + local before = count_tabs() + local orig_tab = vim.api.nvim_get_current_tabpage() + + local ok, err = pcall(run) + + local after = count_tabs() + local cur_tab = vim.api.nvim_get_current_tabpage() + local stranded = after > before + local refocused = cur_tab == orig_tab + + out(("\n[%s]"):format(name)) + out((" setup result : %s"):format(ok and "OK (no error -- unexpected)" or "ERROR (expected)")) + if not ok then + local msg = type(err) == "table" and (tostring(err.message) .. " / " .. tostring(err.data)) or tostring(err) + -- Keep enough of the message that the underlying cause (e.g. the BufReadPre failure in + -- scenario B) is visible past Neovim's nvim_exec2 wrapper prefix. + out((" error : %s"):format((msg:gsub("%s+", " ")):sub(1, 240))) + end + out((" tabs : before=%d after=%d %s"):format(before, after, stranded and "<< STRANDED" or "(clean)")) + out((" focus : %s"):format(refocused and "back on original tab" or "LEFT ON STRANDED TAB")) + + return { name = name, stranded = stranded, refocused = refocused, before = before, after = after } +end + +out("== issue #262 reproduction (open_in_new_tab strands a tab on early setup error) ==") +out(("Neovim: %s"):format(tostring(vim.version()))) + +-- Scenario A: deterministic. Stub _create_diff_view_from_window to throw. In +-- _setup_blocking_diff this is called (line ~1252) AFTER display_terminal_in_new_tab() +-- has already run `:tabnew` (line ~1173) and AFTER new_buffer is created, but BEFORE +-- _register_diff_state. This isolates the exact "error between tab creation and state +-- registration" window the issue describes, independent of any specific trigger. +local results = {} +results[#results + 1] = run_scenario("A: deterministic (stub _create_diff_view_from_window)", function() + local old_file = make_old_file("A") + local original = diff._create_diff_view_from_window + diff._create_diff_view_from_window = function() + error({ code = -32000, message = "simulated failure", data = "after tabnew, before register" }) + end + local fin = function() + diff._create_diff_view_from_window = original + os.remove(old_file) + end + local ok, err = pcall(function() + diff._setup_blocking_diff({ + old_file_path = old_file, + new_file_path = old_file, + new_file_contents = "# proposed by Claude\n\nNEW line one\nline two\n", + tab_name = "✻ [Claude Code] repro262_A.md ⧉", + }, function() end) + end) + fin() + if not ok then + error(err) + end +end) + +-- Scenario B: realistic trigger from the issue. A user BufReadPre autocmd that +-- throws when the original file is :edit-ed (load_original_buffer -> +-- `vim.cmd("edit " .. fnameescape(old_file_path))`). This is a real-world path: +-- a plugin/autocmd erroring on read, a swap-file conflict, etc. all surface here. +results[#results + 1] = run_scenario("B: realistic (throwing BufReadPre autocmd on :edit)", function() + local old_file = make_old_file("B") + local grp = vim.api.nvim_create_augroup("repro262_bufreadpre", { clear = true }) + vim.api.nvim_create_autocmd("BufReadPre", { + group = grp, + pattern = old_file, + callback = function() + error("simulated BufReadPre failure (#262 realistic trigger)") + end, + }) + local fin = function() + pcall(vim.api.nvim_del_augroup_by_id, grp) + os.remove(old_file) + end + local ok, err = pcall(function() + diff._setup_blocking_diff({ + old_file_path = old_file, + new_file_path = old_file, + new_file_contents = "# proposed by Claude\n\nNEW line one\nline two\n", + tab_name = "✻ [Claude Code] repro262_B.md ⧉", + }, function() end) + end) + fin() + if not ok then + error(err) + end +end) + +-- Control: same open_in_new_tab path, but setup SUCCEEDS (no forced error) and is +-- then torn down via the normal cleanup (_cleanup_diff_state). This proves the +-- harness is sound: a `:tabnew`-created tab that is properly registered DOES get +-- closed, so the 1 -> 2 growth in A/B above is a genuine leak, not just "tabnew +-- always adds a tab". +local control = (function() + reset_layout() + local before = count_tabs() + local tab_name = "✻ [Claude Code] repro262_control.md ⧉" + local old_file = make_old_file("control") + local mid, after + local ok, err = pcall(function() + diff._setup_blocking_diff({ + old_file_path = old_file, + new_file_path = old_file, + new_file_contents = "# proposed by Claude\n\nNEW line one\nline two\n", + tab_name = tab_name, + }, function() end) + mid = count_tabs() -- tab created and registered + diff._cleanup_diff_state(tab_name, "repro control cleanup") + end) + after = count_tabs() + os.remove(old_file) + out("\n[C: control (setup succeeds, then normal cleanup)]") + out((" setup result : %s"):format(ok and "OK (expected)" or ("ERROR -- " .. tostring(err)))) + out( + (" tabs : before=%d during=%s after_cleanup=%d %s"):format( + before, + tostring(mid), + after, + (after == before) and "(cleaned up)" or "<< LEAK" + ) + ) + return { ok = ok, before = before, mid = mid, after = after, clean = (after == before) } +end)() + +out("\n== verdict ==") +local any_stranded = false +for _, r in ipairs(results) do + if r.stranded then + any_stranded = true + out( + ("BUG REPRODUCED [%s]: %d -> %d tabs (one stranded)%s"):format( + r.name, + r.before, + r.after, + r.refocused and "" or "; original tab not refocused" + ) + ) + else + out(("OK [%s]: tab count unchanged (%d)"):format(r.name, r.after)) + end +end + +out( + ("CONTROL [C]: %s"):format( + control.clean and "harness sound (registered tab is cleaned up; leak in A/B is real)" + or "WARNING -- control did not clean up; harness suspect" + ) +) + +if any_stranded then + out("\n=> #262 confirmed: open_in_new_tab strands a tab when setup errors before the diff state is registered.") +else + out("\n=> FIXED: no tab was stranded on early setup failure.") +end + +io.stdout:flush() +vim.cmd("cquit " .. (any_stranded and 1 or 0)) diff --git a/tests/unit/diff_new_tab_strand_spec.lua b/tests/unit/diff_new_tab_strand_spec.lua new file mode 100644 index 00000000..393f5b6e --- /dev/null +++ b/tests/unit/diff_new_tab_strand_spec.lua @@ -0,0 +1,184 @@ +-- Regression test for issue #262: +-- "diff: open_in_new_tab can strand a tab if setup errors before the diff +-- state is registered" +-- https://github.com/coder/claudecode.nvim/issues/262 +-- +-- With diff_opts.open_in_new_tab = true, M._setup_blocking_diff calls +-- display_terminal_in_new_tab() early (it runs `:tabnew`). If setup then throws +-- before M._register_diff_state runs, the pre-registration cleanup branch must +-- close that stranded tab and refocus the original one -- mirroring how #260 +-- handles the fallback window / proposed buffer on the same error path. +-- +-- Before the fix, `new_tab_handle` was declared inside the pcall closure, so the +-- error handler could not reach it: one extra tab was stranded per failed setup +-- and focus was left on it. +require("tests.busted_setup") + +local function count_tabs() + local n = 0 + for _ in pairs(vim._tabs) do + n = n + 1 + end + return n +end + +describe("Diff open_in_new_tab cleanup on early setup error (issue #262)", function() + local diff + + before_each(function() + -- Start from a single, clean tab/window model. + vim._mock.reset() + vim._tabs = { [1] = true } + vim._current_tabpage = 1 + vim._current_window = 1000 + vim._next_winid = 1001 + vim._mock.add_buffer(1, "/home/user/project/test.lua", "local x = 1\n") + vim._mock.add_window(1000, 1, { 1, 0 }) + vim._win_tab[1000] = 1 + vim._tab_windows[1] = { 1000 } + + package.loaded["claudecode.logger"] = { + debug = function() end, + error = function() end, + info = function() end, + warn = function() end, + } + + -- Stub the terminal provider with a valid terminal buffer so the + -- open_in_new_tab path is exercised as in real usage. + local term_buf = vim.api.nvim_create_buf(false, true) + package.loaded["claudecode.terminal"] = { + get_active_terminal_bufnr = function() + return term_buf + end, + ensure_visible = function() end, + } + + package.loaded["claudecode.diff"] = nil + diff = require("claudecode.diff") + diff.setup({ + diff_opts = { + layout = "vertical", + open_in_new_tab = true, + -- Return right after `:tabnew` (skip the terminal vsplit) so the test + -- targets the tab leak itself, not terminal-split window plumbing. + hide_terminal_in_new_tab = true, + keep_terminal_focus = false, + on_new_file_reject = "keep_empty", + }, + terminal = {}, + }) + end) + + after_each(function() + if diff and diff._cleanup_all_active_diffs then + diff._cleanup_all_active_diffs("test teardown") + end + package.loaded["claudecode.diff"] = nil + package.loaded["claudecode.logger"] = nil + package.loaded["claudecode.terminal"] = nil + end) + + it("creates exactly one new tab on the open_in_new_tab path (precondition)", function() + -- Sanity: when setup SUCCEEDS, a tab is created and then cleaned up. This + -- proves the harness/mock models tab creation+teardown, so the leak assertion + -- below is meaningful. + local tab_name = "✻ [Claude Code] ok.md ⧉" + local tabs_before = count_tabs() + local ok = pcall(function() + diff._setup_blocking_diff({ + old_file_path = "/nonexistent/ok.md", + new_file_path = "/nonexistent/ok.md", + new_file_contents = "hello\n", + tab_name = tab_name, + }, function() end) + end) + assert.is_true(ok) + assert.is_table(diff._get_active_diffs()[tab_name]) + assert.is_true(count_tabs() > tabs_before) -- a tab was created + diff._cleanup_diff_state(tab_name, "precondition cleanup") + assert.equals(tabs_before, count_tabs()) -- and cleaned up + end) + + it("does not strand a tab when setup errors before the diff state is registered", function() + -- Force a failure AFTER display_terminal_in_new_tab() ran `:tabnew` but + -- BEFORE _register_diff_state. + diff._create_diff_view_from_window = function() + error({ code = -32000, message = "boom (before registration, after tabnew)" }) + end + + local tabs_before = count_tabs() + local original_tab = vim.api.nvim_get_current_tabpage() + local tab_name = "✻ [Claude Code] err.md ⧉" + + local ok = pcall(function() + diff._setup_blocking_diff({ + old_file_path = "/nonexistent/err.md", + new_file_path = "/nonexistent/err.md", + new_file_contents = "x\n", + tab_name = tab_name, + }, function() end) + end) + + assert.is_false(ok) -- setup is expected to fail + assert.is_nil(diff._get_active_diffs()[tab_name]) -- no diff state registered + -- The bug: the `:tabnew` tab is left open. After the fix, the error handler + -- closes it, so the tab count returns to baseline... + assert.equals(tabs_before, count_tabs()) + -- ...and focus is restored to the original tab (not left on the stranded one). + assert.is_true(vim.api.nvim_tabpage_is_valid(original_tab)) + assert.equals(original_tab, vim.api.nvim_get_current_tabpage()) + end) + + -- display_terminal_in_new_tab() runs `:tabnew` and only THEN does its window setup. If that + -- setup throws (e.g. a vsplit/window failure), Lua's multiple-assignment leaves new_tab_handle + -- unassigned -- so the error handler can't rely on the returned handle. It must fall back to the + -- current tab (which is still the just-created one). Regression guard for that deeper case. + it("does not strand a tab when display_terminal_in_new_tab throws after :tabnew", function() + -- Do NOT hide the terminal, so the helper proceeds past `:tabnew` into the window setup, then + -- force the first nvim_win_set_buf (inside the helper, after the tab is created) to throw. + diff.setup({ + diff_opts = { + layout = "vertical", + open_in_new_tab = true, + hide_terminal_in_new_tab = false, + on_new_file_reject = "keep_empty", + }, + terminal = {}, + }) + + local real_set_buf = vim.api.nvim_win_set_buf + local threw = false + vim.api.nvim_win_set_buf = function(...) + if not threw then + threw = true + error("simulated post-tabnew failure inside display_terminal_in_new_tab") + end + return real_set_buf(...) + end + + local tabs_before = count_tabs() + local original_tab = vim.api.nvim_get_current_tabpage() + local tab_name = "✻ [Claude Code] helper-throw.md ⧉" + + local ok = pcall(function() + diff._setup_blocking_diff({ + old_file_path = "/nonexistent/helper-throw.md", + new_file_path = "/nonexistent/helper-throw.md", + new_file_contents = "x\n", + tab_name = tab_name, + }, function() end) + end) + + vim.api.nvim_win_set_buf = real_set_buf + + assert.is_true(threw) -- the injected failure actually fired inside the helper + assert.is_false(ok) + assert.is_nil(diff._get_active_diffs()[tab_name]) + -- The tab was created inside the helper (new_tab_handle never returned); the current-tab + -- fallback must still close it and restore focus. + assert.equals(tabs_before, count_tabs()) + assert.is_true(vim.api.nvim_tabpage_is_valid(original_tab)) + assert.equals(original_tab, vim.api.nvim_get_current_tabpage()) + end) +end)