Skip to content
Merged
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
4 changes: 4 additions & 0 deletions rootshell.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -652,6 +653,7 @@
992A6D1173F54B70A4EDBA3E /* joe */ = {isa = PBXFileReference; lastKnownFileType = folder; name = joe; path = Resources/joe; sourceTree = "<group>"; };
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 */
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
129 changes: 122 additions & 7 deletions rootshell/Features/Tmux/TmuxController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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?)] =
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)")
Expand Down
36 changes: 36 additions & 0 deletions rootshell/Features/Tmux/TmuxWindowCloseState.swift
Original file line number Diff line number Diff line change
@@ -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<Int>) {
requests = requests.filter { windowIDs.contains($0.key) }
}

static func matchesGateway(owner: UUID, bindingParent: UUID, tabOwner: UUID?) -> Bool {
owner == bindingParent && (tabOwner == nil || tabOwner == owner)
}
}
59 changes: 33 additions & 26 deletions rootshell/UI/Shell/MainView+TabManagement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Expand All @@ -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
Expand Down
Loading