diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index e90fa1d4..0bcd5ea6 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -896,6 +896,26 @@ class TabManager: ObservableObject { /// This should never prompt: the process is already gone, and Ghostty emits the /// `SHOW_CHILD_EXITED` action specifically so the host app can decide what to do. func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard tab.panels[surfaceId] != nil else { return } + + // Child-exit on the last panel should collapse the workspace, matching explicit close + // semantics (and close the window when it was the last workspace). + if tab.panels.count <= 1 { + if tabs.count <= 1 { + if let app = AppDelegate.shared { + app.notificationStore?.clearNotifications(forTabId: tabId) + app.closeMainWindowContainingTabId(tabId) + } else { + // Headless/test fallback when no AppDelegate window context exists. + closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) + } + } else { + closeWorkspace(tab) + } + return + } + closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0864f9d5..27e86aec 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -877,6 +877,78 @@ final class WorkspaceReorderTests: XCTestCase { } } +@MainActor +final class TabManagerChildExitCloseTests: XCTestCase { + func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace() + let third = manager.addWorkspace() + + manager.selectWorkspace(second) + XCTAssertEqual(manager.selectedTabId, second.id) + + guard let secondPanelId = second.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId) + + XCTAssertEqual(manager.tabs.map(\.id), [first.id, third.id]) + XCTAssertEqual( + manager.selectedTabId, + third.id, + "Expected selection to stay at the same index after deleting the selected workspace" + ) + } + + func testChildExitOnLastPanelInLastWorkspaceSelectsPreviousWorkspace() { + let manager = TabManager() + let first = manager.tabs[0] + let second = manager.addWorkspace() + + manager.selectWorkspace(second) + XCTAssertEqual(manager.selectedTabId, second.id) + + guard let secondPanelId = second.focusedPanelId else { + XCTFail("Expected focused panel in selected workspace") + return + } + + manager.closePanelAfterChildExited(tabId: second.id, surfaceId: secondPanelId) + + XCTAssertEqual(manager.tabs.map(\.id), [first.id]) + XCTAssertEqual( + manager.selectedTabId, + first.id, + "Expected previous workspace to be selected after closing the last-index workspace" + ) + } + + func testChildExitOnNonLastPanelClosesOnlyPanel() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let initialPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused panel") + return + } + + guard let splitPanel = workspace.newTerminalSplit(from: initialPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panel to be created") + return + } + + let panelCountBefore = workspace.panels.count + manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: splitPanel.id) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertEqual(manager.tabs.first?.id, workspace.id) + XCTAssertEqual(workspace.panels.count, panelCountBefore - 1) + XCTAssertNotNil(workspace.panels[initialPanelId], "Expected sibling panel to remain") + } +} + @MainActor final class TabManagerPendingUnfocusPolicyTests: XCTestCase { func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {