diff --git a/rootshell.xcodeproj/project.pbxproj b/rootshell.xcodeproj/project.pbxproj index fbabd69e1..5591eebe1 100644 --- a/rootshell.xcodeproj/project.pbxproj +++ b/rootshell.xcodeproj/project.pbxproj @@ -281,6 +281,7 @@ F9641A2175775B2B2284EFF2 /* AppIconRadicalDracula.icon in Resources */ = {isa = PBXBuildFile; fileRef = 478D16DE2F9FC42B007B574B /* AppIconRadicalDracula.icon */; }; FA804455DA96B007ACED884C /* network_ios in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, xros, ); productRef = 47NWPKG12FF10000AABB0001 /* network_ios */; }; 47AF04300000000000000A01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04300000000000000B01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift */; }; + 47AF04310000000000000A01 /* rootshell/Features/Tmux/TmuxWindowCloseState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47AF04310000000000000B01 /* rootshell/Features/Tmux/TmuxWindowCloseState.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -652,6 +653,7 @@ 992A6D1173F54B70A4EDBA3E /* joe */ = {isa = PBXFileReference; lastKnownFileType = folder; name = joe; path = Resources/joe; sourceTree = ""; }; DD1249A5776E91521A4BEA8A /* rootshell.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = rootshell.app; sourceTree = BUILT_PRODUCTS_DIR; }; 47AF04300000000000000B01 /* rootshell/Features/Multiplexer/MuxSessionTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = rootshell/Features/Multiplexer/MuxSessionTarget.swift; sourceTree = SOURCE_ROOT; }; + 47AF04310000000000000B01 /* rootshell/Features/Tmux/TmuxWindowCloseState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = rootshell/Features/Tmux/TmuxWindowCloseState.swift; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -1244,6 +1246,7 @@ 47AC7E5700000000000000B7 /* rootshell/Features/Herdr/HerdrReplyFilter.swift */, 47AC7E5700000000000000B8 /* rootshell/Core/Terminal/TerminalGridReports.swift */, 47AF04870000000000000B01 /* rootshell/Core/Terminal/PaneCloseConfirmationPolicy.swift */, + 47AF04310000000000000B01 /* rootshell/Features/Tmux/TmuxWindowCloseState.swift */, 47AF04990000000000000B01 /* rootshell/Core/Keybinds/MenuKeyEquivalentPolicy.swift */, 47AF04960000000000000B01 /* rootshell/Core/Terminal/RemoteSessionBackgroundGracePolicy.swift */, 47AF04850000000000000B01 /* rootshell/Core/Terminal/TerminalScrollbarAvailabilityPolicy.swift */, @@ -2250,6 +2253,7 @@ 47AC7E5700000000000000A7 /* rootshell/Features/Herdr/HerdrReplyFilter.swift in Sources */, 47AC7E5700000000000000A8 /* rootshell/Core/Terminal/TerminalGridReports.swift in Sources */, 47AF04870000000000000A01 /* rootshell/Core/Terminal/PaneCloseConfirmationPolicy.swift in Sources */, + 47AF04310000000000000A01 /* rootshell/Features/Tmux/TmuxWindowCloseState.swift in Sources */, 47AF04990000000000000A01 /* rootshell/Core/Keybinds/MenuKeyEquivalentPolicy.swift in Sources */, 47AF04960000000000000A01 /* rootshell/Core/Terminal/RemoteSessionBackgroundGracePolicy.swift in Sources */, 47AF04850000000000000A01 /* rootshell/Core/Terminal/TerminalScrollbarAvailabilityPolicy.swift in Sources */, diff --git a/rootshell/Features/Tmux/TmuxController.swift b/rootshell/Features/Tmux/TmuxController.swift index d6f78cb2b..158baea25 100644 --- a/rootshell/Features/Tmux/TmuxController.swift +++ b/rootshell/Features/Tmux/TmuxController.swift @@ -380,6 +380,8 @@ final class TmuxController { /// ROOTSHELL-TMUX (id=tmux-gateway-surface-freed) private(set) var ownerSurfaceFreed = false + private var windowCloseState = TmuxWindowCloseState() + /// Whether ESC on the gateway should detach. var isActive: Bool { !isDetaching && !windowTabs.isEmpty } @@ -742,7 +744,8 @@ final class TmuxController { private func topologyStateCoherent() -> Bool { guard !windowTabs.isEmpty, !isDetaching, !didEnd else { return true } let coherent = windowTabs.allSatisfy { windowId, tab in - hostTabsModel(forWindowId: windowId).tabs.contains(where: { $0.id == tab.id }) + windowCloseState.contains(windowId) || + hostTabsModel(forWindowId: windowId).tabs.contains(where: { $0.id == tab.id }) } if !coherent { TmuxDebugLogger.shared.event("RECONCILE", "dedup bypass: stale window tab; applying") @@ -788,6 +791,13 @@ final class TmuxController { private func ensureWindow(_ windowId: Int, index: Int) { let hostModel = hostTabsModel(forWindowId: windowId) + + // Keep the retained projection out of the UI until confirmation or rollback. + if windowCloseState.contains(windowId) { + windowTabs[windowId]?.tmuxWindowIndex = index + return + } + // (id=tmux-window-order) if let existing = windowTabs[windowId] { if hostModel.tabs.contains(where: { $0 === existing }) || isDetaching || didEnd { @@ -1468,7 +1478,7 @@ final class TmuxController { } private func setFocus(windowId: Int, paneId: Int) { - guard let tab = windowTabs[windowId] else { return } + guard !windowCloseState.contains(windowId), let tab = windowTabs[windowId] else { return } let hostModel = hostTabsModel(forWindowId: windowId) // ROOTSHELL-TMUX (id=tmux-session-switch-focus) let isSessionSwitchFocus = pendingSessionSwitchWindowSelection == nil @@ -1527,6 +1537,8 @@ final class TmuxController { let priorWindowCount = windowTabs.count let hostIdsBeforePrune = Set(windowHostIds.values + [baseWindowId]) + windowCloseState.prune(keeping: windowIds) + // Snapshot order and selection so a closed selected tab lands on its // neighbor. ROOTSHELL-TMUX (id=grouped-close-neighbor) let hostSnapshots: [String: (order: [UUID], selectedID: UUID?, groupedNeighborID: UUID?)] = @@ -2072,11 +2084,39 @@ final class TmuxController { windowTabs[windowId]?.splitTree.zoomed != nil } - /// Nil for non-tmux tabs and placeholders without live panes. + /// Resolve the controller projecting a tmux window TAB. Prefer a live pane + /// binding whose parent surface still maps to an active controller; when the + /// tree is empty or bindings are stale (common for background windows right + /// after detach → reattach), fall back to `owningGatewayTerminalUUID`, then + /// to whichever active controller still projects this tab. static func controller(forWindowTab tab: TabModel) -> TmuxController? { for view in tab.splitTree.terminalLeaves { - if let binding = view.tmuxPaneBinding { - return controller(forOwnerSurface: binding.parentSurface) + if let binding = view.tmuxPaneBinding, + let controller = controller(forOwnerSurface: binding.parentSurface), + controller.isActive, !controller.ownerSurfaceFreed, + TmuxWindowCloseState.matchesGateway( + owner: controller.ownerTerminalUUID, bindingParent: binding.parentUUID, + tabOwner: tab.owningGatewayTerminalUUID) { + return controller + } + } + if let owner = tab.owningGatewayTerminalUUID { + for (_, weak) in controllersByOwnerSurface { + guard let controller = weak.controller, + controller.ownerTerminalUUIDForNotifications == owner, + controller.isActive, !controller.ownerSurfaceFreed else { continue } + return controller + } + } + // Last resort: identity in windowTabs (UUID may be missing after a + // reconnect race, but the controller still owns the projection). + if let windowId = tab.tmuxWindowId { + for (_, weak) in controllersByOwnerSurface { + guard let controller = weak.controller, controller.isActive, + !controller.ownerSurfaceFreed else { continue } + if controller.windowTabs[windowId] === tab { + return controller + } } } return nil @@ -2091,13 +2131,87 @@ final class TmuxController { // MARK: - Hidden-window bridges (state is private; the logic lives in // TmuxController+HiddenWindows.swift) (id=tmux-hidden-windows) + /// Remove the tab immediately, retaining its projection until server prune. + /// A failed command or missing confirmation restores it without a reconcile. + @discardableResult + func requestKillWindow(windowId: Int) -> Bool { + guard !didEnd, !isDetaching, !ownerSurfaceFreed, + ghosttyApp?.surfaceView(for: ownerSurface)?.uuid == ownerTerminalUUID, + let tab = windowTabs[windowId] else { return false } + if windowCloseState.contains(windowId) { return true } + let hostModel = hostTabsModel(forWindowId: windowId) + guard let index = hostModel.tabs.firstIndex(where: { $0 === tab }), + let request = windowCloseState.begin(windowID: windowId, tabIndex: index) else { return false } + + let hostId = hostWindowId(forWindowId: windowId) + let priorOrder = hostModel.tabs.map(\.id) + let selectedID = hostModel.selectedTabID + let groupedNeighborID = selectedID.flatMap { hostModel.groupedCloseNeighbor(for: $0) } + clearPendingSplitFocus(windowId: windowId) + for view in tab.splitTree.terminalLeaves { + view.isLogicallyFocused = false + view.shouldBecomeFirstResponderWhenReady = false + view.setOcclusion(false) + } + hostModel.tabs.removeAll { $0.id == tab.id } + // Keep windowTabs/paneViews and their host mapping until prune, including + // the last window: prune must still run the control-mode end teardown. + enforceGatewayVisibleWhenGroupHidden() + if let selectedID, selectedID == tab.id, + let neighborID = survivingGroupedOrNearestNeighbor( + in: hostModel, groupedCandidateID: groupedNeighborID, + priorOrder: priorOrder, removedID: tab.id) { + hostModel.selectedTabID = neighborID + hostModel.pendingScrollToTabID = neighborID + } else { + hostModel.repairSelectionIfNeeded() + } + TerminalWindowRegistry.refreshSelectionAfterMutation(in: hostId, allowFocus: true) + + Task { @MainActor [weak self] in + guard let self else { return } + guard !self.didEnd, !self.isDetaching, !self.ownerSurfaceFreed, + self.ghosttyApp?.surfaceView(for: self.ownerSurface)?.uuid == self.ownerTerminalUUID else { + self.restorePendingWindowClose(windowId: windowId, request: request) + return + } + do { + self.lastCommandAt = Date() + _ = try await self.sendCommandWithReply("kill-window -t @\(windowId)", timeout: .seconds(2)) + // A successful reply normally follows a confirming prune. Allow + // delayed delivery, then restore if the window is still known. + try await Task.sleep(for: .seconds(2)) + } catch { + TmuxDebugLogger.shared.event("CLOSE", "kill-window @\(windowId) failed: \(error)") + } + self.restorePendingWindowClose(windowId: windowId, request: request) + } + return true + } + + private func restorePendingWindowClose(windowId: Int, request: TmuxWindowCloseState.Request) { + guard !didEnd, !isDetaching, !ownerSurfaceFreed, weakTabsModel != nil, + let index = windowCloseState.restore(windowID: windowId, request: request), + let tab = windowTabs[windowId] else { return } + let hostModel = hostTabsModel(forWindowId: windowId) + if !hostModel.tabs.contains(where: { $0 === tab }) { + hostModel.tabs.insert(tab, at: min(index, hostModel.tabs.count)) + } + reArmWindowSize(windowId: windowId) + reorderTmuxTabsByIndex() + hostModel.repairSelectionIfNeeded() + TerminalWindowRegistry.refreshSelectionAfterMutation( + in: hostWindowId(forWindowId: windowId), allowFocus: false) + } + + /// The tab projecting a tmux window, if any. func windowTab(forWindowId windowId: Int) -> TabModel? { windowTabs[windowId] } /// Gates hiding the gateway tab. (id=tmux-hidden-gateway) var hasVisibleWindowTabs: Bool { - windowTabs.values.contains { !$0.isHiddenTmuxWindow } + windowTabs.contains { !windowCloseState.contains($0.key) && !$0.value.isHiddenTmuxWindow } } /// Re-flags tabs created before the `@hidden` reply landed. @@ -2187,7 +2301,8 @@ final class TmuxController { /// `refresh-client -C @win:WxH`, pinning each window independently. func pushWindowSize(windowId: Int, cols: UInt16, rows: UInt16) { - guard !didEnd, !isDetaching, !ownerSurfaceFreed else { return } + guard !didEnd, !isDetaching, !ownerSurfaceFreed, + !windowCloseState.contains(windowId) else { return } guard cols >= Self.minPushCols, rows >= Self.minPushRows else { // Transient; don't latch the dedup. TmuxDebugLogger.shared.event("CMD-REJECT", "window size below floor win=\(windowId) cols=\(cols) rows=\(rows)") diff --git a/rootshell/Features/Tmux/TmuxWindowCloseState.swift b/rootshell/Features/Tmux/TmuxWindowCloseState.swift new file mode 100644 index 000000000..dce60b331 --- /dev/null +++ b/rootshell/Features/Tmux/TmuxWindowCloseState.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Pending UI removals. The controller retains the actual tabs and panes until +/// an authoritative prune, so a rejected or lost kill can restore them safely. +struct TmuxWindowCloseState { + struct Request: Equatable { + let id = UUID() + let tabIndex: Int + } + + private var requests: [Int: Request] = [:] + + func contains(_ windowID: Int) -> Bool { requests[windowID] != nil } + + mutating func begin(windowID: Int, tabIndex: Int) -> Request? { + guard requests[windowID] == nil else { return nil } + let request = Request(tabIndex: tabIndex) + requests[windowID] = request + return request + } + + /// A reply/timeout from an earlier close must not undo a later close. + mutating func restore(windowID: Int, request: Request) -> Int? { + guard requests[windowID] == request else { return nil } + requests.removeValue(forKey: windowID) + return request.tabIndex + } + + mutating func prune(keeping windowIDs: Set) { + requests = requests.filter { windowIDs.contains($0.key) } + } + + static func matchesGateway(owner: UUID, bindingParent: UUID, tabOwner: UUID?) -> Bool { + owner == bindingParent && (tabOwner == nil || tabOwner == owner) + } +} diff --git a/rootshell/UI/Shell/MainView+TabManagement.swift b/rootshell/UI/Shell/MainView+TabManagement.swift index 054f84146..a5c89ce4a 100644 --- a/rootshell/UI/Shell/MainView+TabManagement.swift +++ b/rootshell/UI/Shell/MainView+TabManagement.swift @@ -823,42 +823,49 @@ extension MainView { extension MainView { - /// Dispatch the user-configured close action for a tmux -CC window tab - /// (the tab's ✕ button, or ⌘W on a single-pane tmux window). Returns true - /// when it handled the close — the caller must NOT tear the tab down - /// locally: the server reconcile (or the chosen action) drives teardown. - /// Returns false when the tab isn't a live tmux window tab so the caller - /// falls back to a normal local close. (id=tmux-tab-close-action) + /// Route a tmux -CC window-tab close to the server (or the configured + /// close action). Must NOT require a live pane in the tab's split tree — + /// after detach → reattach, background windows often have empty or stale + /// trees; a local close then self-heals the tab back into existence. + /// Always resolve the gateway via `controller(forWindowTab:)` and kill by + /// `tmuxWindowId` on that controller. (id=tmux-tab-close-action) @MainActor func handleTmuxWindowTabClose(_ tab: TerminalTab) -> Bool { - guard tab.isTmuxWindow, let windowId = tab.tmuxWindowId, - let pane = tab.splitTree.terminalLeaves.first(where: { $0.isTmuxPane }), - let binding = pane.tmuxPaneBinding, - let controller = TmuxController.controller(forOwnerSurface: binding.parentSurface), - controller.isActive else { return false } + guard tab.isTmuxWindow, let windowId = tab.tmuxWindowId else { return false } + + guard let controller = TmuxController.controller(forWindowTab: tab), + !controller.didEnd, !controller.isDetaching else { + TmuxDebugLogger.shared.event( + "CLOSE", + "window-tab close: no live controller win=\(windowId) owner=\(tab.owningGatewayTerminalUUID?.uuidString.prefix(8) ?? "nil")" + ) + return false + } let action = TmuxTabCloseAction.current if action == .ask { pendingTmuxCloseTabID = tab.id return true } - return performTmuxClose(action, tab: tab, pane: pane, - controller: controller, windowId: windowId) + return performTmuxClose(action, controller: controller, windowId: windowId) } /// Perform a concrete tmux tab-close action (never resolves `.ask`). - /// Factored out so the "Ask Each Time" action sheet can invoke each branch - /// directly. (id=tmux-tab-close-action) + /// Kill/hide/detach always run via the gateway controller so empty or + /// stale background window trees still close after detach → reattach. + /// (id=tmux-tab-close-action) @MainActor @discardableResult func performTmuxClose(_ action: TmuxTabCloseAction, - tab: TerminalTab, - pane: Ghostty.TerminalView, controller: TmuxController, windowId: Int) -> Bool { switch action { case .closeWindow: - return pane.requestTmuxKillWindow() + // Always kill through the live gateway controller. Pane-view + // `requestTmuxKillWindow` can return true while silently dropping + // on a stale parent surface after detach→reattach, leaving the + // tab stuck; or return false without this fallback. + return controller.requestKillWindow(windowId: windowId) case .detachSession: controller.requestGracefulDetach(source: "tab-close") return true @@ -876,10 +883,12 @@ extension MainView { if controller.hideWindow(windowId: windowId) { return true } - return pane.requestTmuxKillWindow() + return controller.requestKillWindow(windowId: windowId) case .ask: // Safety net: a re-prompt instead of silently dropping the close. - pendingTmuxCloseTabID = tab.id + if let tab = controller.windowTab(forWindowId: windowId) { + pendingTmuxCloseTabID = tab.id + } return true } } @@ -892,12 +901,10 @@ extension MainView { defer { pendingTmuxCloseTabID = nil } guard let id = pendingTmuxCloseTabID, let tab = terminals.first(where: { $0.id == id }), - tab.isTmuxWindow, let windowId = tab.tmuxWindowId, - let pane = tab.splitTree.terminalLeaves.first(where: { $0.isTmuxPane }), - let binding = pane.tmuxPaneBinding, - let controller = TmuxController.controller(forOwnerSurface: binding.parentSurface), - controller.isActive else { return } - performTmuxClose(action, tab: tab, pane: pane, controller: controller, windowId: windowId) + tab.isTmuxWindow, let windowId = tab.tmuxWindowId else { return } + guard let controller = TmuxController.controller(forWindowTab: tab), + !controller.didEnd, !controller.isDetaching else { return } + performTmuxClose(action, controller: controller, windowId: windowId) } /// The tmux close-action setting applies to herdr tabs too: close on the diff --git a/rootshellTests/TmuxWindowCloseTests.swift b/rootshellTests/TmuxWindowCloseTests.swift new file mode 100644 index 000000000..1cbcf2e19 --- /dev/null +++ b/rootshellTests/TmuxWindowCloseTests.swift @@ -0,0 +1,64 @@ +import XCTest + +final class TmuxWindowCloseTests: XCTestCase { + func testReusedSurfaceCannotRouteToAnotherGateway() { + let original = UUID() + let replacement = UUID() + XCTAssertFalse(TmuxWindowCloseState.matchesGateway( + owner: replacement, bindingParent: original, tabOwner: original)) + XCTAssertFalse(TmuxWindowCloseState.matchesGateway( + owner: replacement, bindingParent: original, tabOwner: nil)) + XCTAssertFalse(TmuxWindowCloseState.matchesGateway( + owner: replacement, bindingParent: replacement, tabOwner: original)) + XCTAssertTrue(TmuxWindowCloseState.matchesGateway( + owner: original, bindingParent: original, tabOwner: original)) + XCTAssertTrue(TmuxWindowCloseState.matchesGateway( + owner: original, bindingParent: original, tabOwner: nil)) + } + + func testFailedKillRestoresWithoutAnotherTopologyUpdate() throws { + var state = TmuxWindowCloseState() + let request = try XCTUnwrap(state.begin(windowID: 7, tabIndex: 3)) + XCTAssertTrue(state.contains(7)) + XCTAssertEqual(state.restore(windowID: 7, request: request), 3) + XCTAssertFalse(state.contains(7)) + XCTAssertNil(state.restore(windowID: 7, request: request)) + } + + func testUnchangedTopologyDoesNotPreventTimeoutRollback() throws { + var state = TmuxWindowCloseState() + let request = try XCTUnwrap(state.begin(windowID: 7, tabIndex: 3)) + state.prune(keeping: [7, 8]) + state.prune(keeping: [7, 8]) + XCTAssertTrue(state.contains(7)) + XCTAssertEqual(state.restore(windowID: 7, request: request), 3) + } + + func testConfirmedLastWindowCloseCannotBeRestoredByLateTimeout() throws { + var state = TmuxWindowCloseState() + let request = try XCTUnwrap(state.begin(windowID: 7, tabIndex: 1)) + state.prune(keeping: []) + XCTAssertFalse(state.contains(7)) + XCTAssertNil(state.restore(windowID: 7, request: request)) + } + + func testClosingOneWindowDoesNotCancelAnotherPendingKill() throws { + var state = TmuxWindowCloseState() + let first = try XCTUnwrap(state.begin(windowID: 7, tabIndex: 1)) + let second = try XCTUnwrap(state.begin(windowID: 8, tabIndex: 2)) + state.prune(keeping: [8]) + XCTAssertNil(state.restore(windowID: 7, request: first)) + XCTAssertEqual(state.restore(windowID: 8, request: second), 2) + } + + func testDuplicateCloseDoesNotReplaceRequestAndLateReplyCannotUndoRetry() throws { + var state = TmuxWindowCloseState() + let first = try XCTUnwrap(state.begin(windowID: 7, tabIndex: 1)) + XCTAssertNil(state.begin(windowID: 7, tabIndex: 2)) + XCTAssertEqual(state.restore(windowID: 7, request: first), 1) + let retry = try XCTUnwrap(state.begin(windowID: 7, tabIndex: 4)) + XCTAssertNil(state.restore(windowID: 7, request: first)) + XCTAssertTrue(state.contains(7)) + XCTAssertEqual(state.restore(windowID: 7, request: retry), 4) + } +}