import CmuxProductAnalytics import CmuxSSHClipboardTrust import AppKit import CmuxAgentChat import CmuxCloudMachines import CmuxFoundation import CmuxTerminalCore import SwiftUI import Foundation import CmuxTerminalSharing import Bonsplit import CmuxBrowser import CmuxGit import CmuxNotifications import CmuxPanes import CmuxRemoteSession import CmuxSettings import CmuxSidebar import CmuxSidebarGit import CmuxWorkspaces import CoreVideo import Combine import CoreServices import Darwin import OSLog import CmuxTerminal // MARK: - Tab Type Alias for Backwards Compatibility // The old Tab class is replaced by Workspace typealias Tab = Workspace private let tabManagerLogger = Logger(subsystem: "com.cmuxterm.app", category: "TabManager") enum WorkspaceOrderChangeNotificationKey { static let movedWorkspaceIds = "movedWorkspaceIds" } #if DEBUG // Sample the actual IOSurface-backed terminal layer at vsync cadence so UI tests can reliably // catch a single compositor-frame blank flash and any transient compositor scaling (stretched text). // // This is DEBUG-only and used only for UI tests; no polling or display-link loops exist in normal app runtime. fileprivate final class VsyncIOSurfaceTimelineState { struct Target { let label: String let sample: @MainActor () -> GhosttySurfaceScrollView.DebugFrameSample? } let frameCount: Int let closeFrame: Int let lock = NSLock() var framesWritten = 0 var inFlight = false var finished = false var scheduledActions: [(frame: Int, action: () -> Void)] = [] var nextActionIndex: Int = 0 var targets: [Target] = [] // Results var firstBlank: (label: String, frame: Int)? var firstSizeMismatch: (label: String, frame: Int, ios: String, expected: String)? var trace: [String] = [] var link: CVDisplayLink? var continuation: CheckedContinuation? init(frameCount: Int, closeFrame: Int) { self.frameCount = frameCount self.closeFrame = closeFrame } func tryBeginCapture() -> Bool { lock.lock() defer { lock.unlock() } if finished { return false } if inFlight { return false } inFlight = true return true } func endCapture() { lock.lock() inFlight = false lock.unlock() } func finish() { lock.lock() if finished { lock.unlock() return } finished = true let cont = continuation continuation = nil lock.unlock() cont?.resume() } } fileprivate func cmuxVsyncIOSurfaceTimelineCallback( _ displayLink: CVDisplayLink, _ inNow: UnsafePointer, _ inOutputTime: UnsafePointer, _ flagsIn: CVOptionFlags, _ flagsOut: UnsafeMutablePointer, _ ctx: UnsafeMutableRawPointer? ) -> CVReturn { guard let ctx else { return kCVReturnSuccess } let st = Unmanaged.fromOpaque(ctx).takeUnretainedValue() if !st.tryBeginCapture() { return kCVReturnSuccess } // Sample on the main thread synchronously so we don't "miss" a single compositor frame. // (The previous Task/@MainActor hop could be delayed long enough to skip the blank frame.) DispatchQueue.main.sync { defer { st.endCapture() } guard st.framesWritten < st.frameCount else { return } while st.nextActionIndex < st.scheduledActions.count { let next = st.scheduledActions[st.nextActionIndex] if next.frame != st.framesWritten { break } st.nextActionIndex += 1 next.action() } for t in st.targets { guard let s = t.sample() else { continue } let iosW = s.iosurfaceWidthPx let iosH = s.iosurfaceHeightPx let expW = s.expectedWidthPx let expH = s.expectedHeightPx let gravity = s.layerContentsGravity let hasDimensions = iosW > 0 && iosH > 0 && expW > 0 && expH > 0 let dw = hasDimensions ? abs(iosW - expW) : 0 let dh = hasDimensions ? abs(iosH - expH) : 0 let hasSizeMismatch = hasDimensions && (dw > 2 || dh > 2) let stretchRisk = (gravity == CALayerContentsGravity.resize.rawValue) // Ignore setup/warmup frames before the close action. We only care about // regressions that happen at/after the close mutation. if st.firstBlank == nil, st.framesWritten >= st.closeFrame, s.isProbablyBlank { st.firstBlank = (label: t.label, frame: st.framesWritten) } if st.firstSizeMismatch == nil, st.framesWritten >= st.closeFrame, stretchRisk, hasSizeMismatch { st.firstSizeMismatch = ( label: t.label, frame: st.framesWritten, ios: "\(iosW)x\(iosH)", expected: "\(expW)x\(expH)" ) } if st.trace.count < 200 { st.trace.append("\(st.framesWritten):\(t.label):blank=\(s.isProbablyBlank ? 1 : 0):ios=\(iosW)x\(iosH):exp=\(expW)x\(expH):gravity=\(gravity):key=\(s.layerContentsKey)") } } st.framesWritten += 1 } // Stop/resume outside the main-thread sync block to avoid reentrancy issues. if st.framesWritten >= st.frameCount, let link = st.link { CVDisplayLinkStop(link) st.finish() Unmanaged.fromOpaque(ctx).release() } return kCVReturnSuccess } #endif // WorkspaceGroup, WorkspaceReorderPlanItem, WorkspaceBatchReorderError, and // the pure batch-reorder planning live in CmuxWorkspaces. @MainActor class TabManager: ObservableObject { /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). /// Used to apply title updates to the correct window instead of NSApp.keyWindow. weak var window: NSWindow? /// Stable identifier of the owning macOS window. Used only for opt-in title /// templates that expose a WM-matchable per-window token. var windowId: UUID? private(set) var isFinalizedForWindowClose = false private var recoverableMainWindowRouteOwnerRegistration: RecoverableMainWindowRouteOwnerRegistration? func installRecoverableMainWindowRouteOwnerRegistration( _ registration: RecoverableMainWindowRouteOwnerRegistration ) { recoverableMainWindowRouteOwnerRegistration = registration } func clearRecoverableMainWindowRouteOwnerRegistration( for route: RecoverableMainWindowRoute ) { guard recoverableMainWindowRouteOwnerRegistration?.observes(route) == true else { return } recoverableMainWindowRouteOwnerRegistration = nil } // Wave-4 sub-model (TabManager decomposition): the workspace list, the // sidebar group sections, and the selected-workspace id storage live in // WorkspacesModel (CmuxWorkspaces). TabManager stays the per-window // composition point: it owns the model, forwards the legacy accessors // below, and implements WorkspacesHosting (bottom of this file) to run // the legacy @Published property-observer side effects at identical // timing (objectWillChange + bridge publishers in willSet, selection // side effects in didSet). let workspaces = WorkspacesModel() var workspacesById: [UUID: Workspace] { workspaces.tabsById } private let windowDockTitleRoutingStores = NSMapTable.strongToWeakObjects() /// Live window-scope Dock stores registered with this manager (weak /// registry: deallocated stores are skipped automatically). var liveWindowDockStores: [DockSplitStore] { (windowDockTitleRoutingStores.objectEnumerator()?.allObjects as? [DockSplitStore]) ?? [] } let workspaceSwitchCoordinator = WorkspaceSwitchCoordinator() let cloudWorkspaceSelection: CloudWorkspaceSelectionState /// Shared by the inline Cloud sidebar and any Cloud tool pane in this /// window so CodeRouter reads and removals never overlap. let coderouterAccountStore = CoderouterAccountStore() var tabs: [Workspace] { get { workspaces.tabs } set { workspaces.tabs = newValue } } /// Named groupings of workspaces shown as collapsible sections in the sidebar. /// Group order in this array defines section order in the sidebar. /// Each member workspace stores its `groupId` on the `Workspace` model. var workspaceGroups: [WorkspaceGroup] { get { workspaces.workspaceGroups } set { workspaces.workspaceGroups = newValue } } /// Legacy Combine bridge for the remaining `tabManager.$tabs` /// subscribers. Driven exclusively from `workspaceTabsWillChange(to:)`, /// so it emits the new value during willSet and replays the current /// value on subscribe — the exact `Published.Publisher` semantics those /// call sites were written against. Single seam; delete when the /// subscribers move to @Observable observation. let tabsPublisher = CurrentValueSubject<[Workspace], Never>([]) /// Legacy Combine bridge for the remaining `tabManager.$selectedTabId` /// subscribers; same contract as `tabsPublisher`. let selectedTabIdPublisher = CurrentValueSubject(nil) /// Legacy Combine bridge for the remaining `tabManager.$workspaceGroups` /// subscribers (e.g. MobileWorkspaceListObserver); same contract as /// `tabsPublisher`. Emits during willSet and replays the current value /// on subscribe — the `Published.Publisher` semantics those call sites /// were written against. let workspaceGroupsPublisher = CurrentValueSubject<[WorkspaceGroup], Never>([]) /// Set by `restoreSessionSnapshot` to suppress side-effects (like auto- /// expanding a group on focus) that would mutate restored state mid-restore. private var isRestoringSessionSnapshot: Bool = false @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] @Published private(set) var mountedBackgroundWorkspaceLoadIds: Set = [] @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). static var nextPortOrdinal: Int = 0 var selectedTabId: UUID? { get { workspaces.selectedTabId } set { workspaces.selectedTabId = newValue } } // MARK: - WorkspacesHosting hooks (legacy @Published property observers) /// Legacy `@Published tabs` willSet: objectWillChange plus the Combine /// bridge fire before storage changes, matching @Published timing. func workspaceTabsWillChange(to newValue: [Workspace]) { objectWillChange.send() tabsPublisher.send(newValue) if newValue.count != tabs.count, ProductAnalytics.app.isEnabled { // A window that already had a workspace gaining one, outside // restore, is a created workspace (whatever entrypoint created it). // A new window's first workspace is not. if newValue.count > tabs.count, !tabs.isEmpty, !isRestoringSessionSnapshot { ProductAnalytics.app.reach(.firstWorkspace) } // After the commit, so the count reads the new list. Reorders skip this. Task { @MainActor in AppDelegate.shared?.refreshProductAnalyticsActivityContext() } } } /// Legacy `@Published workspaceGroups` willSet. func workspaceGroupsWillChange(to newValue: [WorkspaceGroup]) { objectWillChange.send() workspaceGroupsPublisher.send(newValue) } /// Legacy `@Published selectedTabId` willSet; `selectedTabId` still /// reads the old value here, exactly like the original property observer. func selectedWorkspaceIdWillChange(to newValue: UUID?) { recordCloudWorkspaceSelection() if !isRestoringSessionSnapshot { let terminalTarget = newValue .flatMap { workspacesById[$0] } .flatMap { $0.focusedTerminalInputTarget() } workspaceSwitchCoordinator.selectionWillCommit( from: selectedTabId, to: newValue, targetSurfaceID: terminalTarget?.surfaceID, targetTerminalView: terminalTarget?.panel.hostedView.surfaceView, targetRendererPresented: terminalTarget?.panel.surface.isRendererPresented == true, targetRenderedFrameSequence: terminalTarget?.panel.hostedView.surfaceView.renderedFrameSequence ?? 0 ) } else { workspaceSwitchCoordinator.cancel() } objectWillChange.send() selectedTabIdPublisher.send(newValue) #if DEBUG guard newValue != selectedTabId else { debugPendingWorkspaceSwitchTrigger = nil debugPendingWorkspaceSwitchTarget = nil debugPreparedWorkspaceSwitchTarget = nil return } if debugPreparedWorkspaceSwitchTarget == newValue { debugPreparedWorkspaceSwitchTarget = nil debugPendingWorkspaceSwitchTrigger = nil debugPendingWorkspaceSwitchTarget = nil } else { let trigger = (debugPendingWorkspaceSwitchTarget == newValue ? debugPendingWorkspaceSwitchTrigger : nil) ?? "direct" debugPendingWorkspaceSwitchTrigger = nil debugPendingWorkspaceSwitchTarget = nil debugBeginWorkspaceSwitch( trigger: trigger, from: selectedTabId, to: newValue ) } #endif } /// Legacy `@Published selectedTabId` didSet: the selection side-effect /// chain, run synchronously after storage changed. func selectedWorkspaceIdDidChange(from oldValue: UUID?) { guard selectedTabId != oldValue else { return } recordCloudWorkspaceSelection() defer { workspaceSwitchCoordinator.selectionDidCommit( from: oldValue, to: selectedTabId ) } pendingProjectedNotificationFocusRequestID = nil if !isRestoringSessionSnapshot { workspaces.expandWorkspaceGroupForSelectionIfNeeded() } if let selectedTabId { workspacesById[selectedTabId]?.admitStartupRestoresAwaitingFirstVisit() } sentryBreadcrumb("workspace.switch", data: [ "tabCount": tabs.count ]) let previousTabId = oldValue if let previousTabId, let previousPanelId = focusedPanelId(for: previousTabId) { lastFocusedPanelByTab[previousTabId] = previousPanelId } if shouldRecordFocusHistory { if let previousTabId { focusHistoryNavigation.recordFocusInHistory( workspaceId: previousTabId, panelId: focusedPanelId(for: previousTabId), preservingForwardBranch: false ) } if let selectedTabId, tabs.contains(where: { $0.id == selectedTabId }) { let selectedEntry = FocusHistoryEntry( workspaceId: selectedTabId, panelId: lastFocusedPanelByTab[selectedTabId] ) focusHistoryNavigation.recordFocusInHistory( workspaceId: selectedTabId, panelId: focusHistoryNavigation.resolvedFocusHistoryPanelId(for: selectedEntry), preservingForwardBranch: false ) } } publishCmuxWorkspaceSelectedChange(from: previousTabId) let notificationDismissalContext = notificationDismissal.takePendingSelectionContext() ?? .activeFocus #if DEBUG let switchId = debugWorkspaceSwitchId let switchDtMs = debugWorkspaceSwitchStartTime > 0 ? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000 : 0 cmuxDebugLog( "ws.select.didSet id=\(switchId) from=\(Self.debugShortWorkspaceId(previousTabId)) " + "to=\(Self.debugShortWorkspaceId(selectedTabId)) dt=\(Self.debugMsText(switchDtMs))" ) #endif selectionSideEffectsGeneration &+= 1 let generation = selectionSideEffectsGeneration workspaceHandoffRetirementGate.reset(forSelectionGeneration: generation) if !shouldRecordFocusHistory { focusHistoryNavigation.markSuppressedSelectionSideEffectGeneration(generation) } DispatchQueue.main.async { [weak self] in guard let self else { return } let suppressFocusHistory = self.focusHistoryNavigation.consumeSuppressedSelectionSideEffectGeneration(generation) guard self.selectionSideEffectsGeneration == generation else { return } let applySelectionSideEffects = { self.focusSelectedTabPanel() self.updateWindowTitleForSelectedTab() if let selectedTabId = self.selectedTabId { self.dismissFocusedPanelNotificationIfActive( tabId: selectedTabId, context: notificationDismissalContext ) } } if suppressFocusHistory { self.focusHistoryNavigation.withFocusHistoryRecordingSuppressed(applySelectionSideEffects) } else { applySelectionSideEffects() } if let retirement = self.workspaceHandoffRetirementGate.markFocusPassCompleted( generation: generation ) { self.completeWorkspaceHandoffRetirement(retirement) } #if DEBUG let dtMs = self.debugWorkspaceSwitchStartTime > 0 ? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000 : 0 cmuxDebugLog( "ws.select.asyncDone id=\(self.debugWorkspaceSwitchId) dt=\(Self.debugMsText(dtMs)) " + "selected=\(Self.debugShortWorkspaceId(self.selectedTabId))" ) #endif } } private var observers: [NSObjectProtocol] = [] private var lastFocusedPanelByTab: [UUID: UUID] = [:] private struct PanelTitleUpdateKey: Hashable { let tabId: UUID let panelId: UUID } private struct PendingPanelTitleUpdate { let title: String /// `title` with any spinner frame removed. Carried alongside so the /// flush can tell an advancing spinner from a changed label. let stableTitle: String weak var sourceSurface: TerminalSurface? let sourceTerminalLifecycleId: UUID } private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: PendingPanelTitleUpdate] = [:] private let panelTitleUpdateCoalescer: NotificationBurstCoalescer /// Deduplicates this manager's final AppKit window-title writes. let windowTitleWriter: WindowTitleWriter // Wave-3 sub-models (TabManager decomposition): TabManager is the // per-window composition point. It owns the concrete sub-models, hosts // their seams, and forwards its legacy entry points. /// Per-panel notification-dismissal flow (CmuxNotifications). let notificationDismissal: any NotificationDismissing = NotificationDismissalModel() private var pendingProjectedNotificationFocusRequestID: UUID? /// Recently-closed browser panel history (CmuxBrowser). let browserModel = BrowserModel() /// Sidebar multi-selection state + sync events (CmuxSidebar). let sidebarMultiSelection = SidebarMultiSelectionModel() /// Typed synchronous settings access (CmuxSettings). private let settings: any SettingsWriting private let settingsCatalog = SettingCatalog() private let defaultWorkspaceWorkingDirectoryProvider: () -> String private let ghosttyWorkingDirectoryInheritanceProvider: () -> GhosttyWorkingDirectoryInheritance let workspaceCustomizationStore: WorkspaceCustomizationStore private var lastFocusHistoryIncludesPanesAndTabs: Bool let nativeSSHConnectionBroker: NativeSSHConnectionBroker let agentChatResumeIntentRecorder: any AgentChatResumeIntentRecording @Published private(set) var focusHistoryRevision: UInt64 = 0 { didSet { guard focusHistoryRevision != oldValue else { return } NotificationCenter.default.post(name: .tabManagerFocusHistoryRevisionDidChange, object: self) } } // The focus-history back/forward stack lives in FocusHistoryModel // (CmuxWorkspaceNavigation); this window is its host via // FocusHistoryHosting and republishes its revision bumps through // `focusHistoryRevision` above. let focusHistoryNavigation: any FocusHistoryNavigating // Stateless split-geometry application (equalize/resize divider moves); // the pure planning lives in CmuxPanes' ExternalTreeNode extensions. let paneLayout = PaneLayoutService() // Reorder/pin flows over the workspaces model (CmuxWorkspaces); owns // the pure batch-reorder planner. let workspaceReordering: WorkspaceReorderCoordinator // Workspace-group lifecycle flows over the workspaces model // (CmuxWorkspaces); creation/teardown/selection invert through // WorkspaceGroupHosting. let workspaceGrouping: WorkspaceGroupCoordinator private var shouldRecordFocusHistory: Bool { focusHistoryNavigation.shouldRecordFocusHistory } private var selectionSideEffectsGeneration: UInt64 = 0 private var workspaceHandoffRetirementGate = WorkspaceHandoffRetirementGate() private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)? var sidebarSelectedWorkspaceIds: Set { sidebarMultiSelection.selectedWorkspaceIds } private var currentWindowTabBarLeadingInset: CGFloat? private var closeConfirmationInFlight = false let closeTabWarningDefaults: UserDefaults let tabDragTransferRegistry: TabDragTransferRegistry /// File-backed panels in every workspace and Dock owned by this window /// share this injected invalidation pipeline. let fileContentChangeCoordinator: FileContentChangeCoordinator var confirmCloseHandler: ((String, String, Bool) -> Bool)? /// Test seam for the "Don't ask again" checkbox next to /// `confirmCloseHandler`: receives the warnings the dialog offers to turn /// off and returns whether the checkbox was ticked. var confirmCloseDontAskAgainHandler: ((CloseWarningKinds) -> Bool)? private var agentPIDSweepTimer: DispatchSourceTimer? #if DEBUG private var debugWorkspaceSwitchCounter: UInt64 = 0 private var debugWorkspaceSwitchId: UInt64 = 0 private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0 private var debugPendingWorkspaceSwitchTrigger: String? private var debugPendingWorkspaceSwitchTarget: UUID? private var debugPreparedWorkspaceSwitchTarget: UUID? #endif #if DEBUG private var didSetupSplitCloseRightUITest = false private var didSetupUITestFocusShortcuts = false private var didSetupChildExitSplitUITest = false private var didSetupChildExitKeyboardUITest = false private var uiTestCancellables = Set() #endif // Process-wide cap on concurrent sidebar git snapshot probes, shared by // every window's SidebarGitMetadataService. A static (not a per-instance // default) on purpose: the cap is per process, not per window, matching // the legacy shared limiter; tests inject their own instance. private static let sharedWorkspaceGitProbeLimiter = WorkspaceGitMetadataProbeLimiter(limit: 2) // The sidebar git/PR subsystem (extracted to CmuxSidebarGit). TabManager // is the per-window composition point: it constructs the concrete // services, stores only the seams, implements SidebarGitHosting // (see TabManager+SidebarGitHosting.swift), and forwards its legacy // entry points. let sidebarGitMetadataService: any SidebarGitMetadataServing let pullRequestProbing: any PullRequestProbing /// GitHub transport state injected process-wide by the app composition root. /// The fallback initializer is retained for isolated `TabManager` tests. let pullRequestProbeService: PullRequestProbeService private let managedDevicePolicy: ManagedDevicePolicy let sshClipboardWriteTrustStore: SSHClipboardWriteTrustStore /// Picks how the automatic first-launch welcome reaches the new terminal. /// Tests replace it to exercise each delivery without depending on the host shell. var welcomeBannerDeliveryResolver: @MainActor () -> WelcomeBannerDelivery = { WelcomeBannerDelivery.current() } /// Types the welcome fallback into `workspace` once its terminal is ready. /// Tests replace it to observe the typed-fallback path. lazy var typedWelcomeSender: @MainActor (Workspace) -> Void = { [weak self] workspace in if let appDelegate = AppDelegate.shared { appDelegate.sendWelcomeCommandWhenReady(to: workspace, markShownOnSend: true) } else { self?.sendWelcomeWhenReady(to: workspace) } } init( initialWorkspaceTitle: String? = nil, initialWorkingDirectory: String? = nil, initialTerminalInput: String? = nil, autoWelcomeIfNeeded: Bool = true, createInitialWorkspace: Bool = true, tabDragTransferRegistry: TabDragTransferRegistry? = nil, commandRunner: any CommandRunning = CommandRunner(), gitMetadataService: GitMetadataService = GitMetadataService(), pullRequestProbeService: PullRequestProbeService? = nil, workspaceGitMetadataReader: (any WorkspaceGitMetadataReading)? = nil, gitPollClock: any GitPollClock = SystemGitPollClock(), gitProbeLimiter: WorkspaceGitMetadataProbeLimiter? = nil, focusHistoryNow: @escaping @MainActor @Sendable () -> Date = { Date() }, panelTitleUpdateCoalescer: NotificationBurstCoalescer? = nil, windowTitleWriter: WindowTitleWriter? = nil, settings: any SettingsWriting = UserDefaultsSettingsClient(defaults: .standard), defaultWorkspaceWorkingDirectoryProvider: @escaping () -> String = { GhosttyWorkingDirectoryResolver( homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path, processWorkingDirectory: FileManager.default.currentDirectoryPath ).resolve( configuredValue: GhosttyConfig.loadForCmux().workingDirectory ) }, ghosttyWorkingDirectoryInheritanceProvider: @escaping () -> GhosttyWorkingDirectoryInheritance = { GhosttyConfig.loadForCmux().workingDirectoryInheritance }, workspaceCustomizationStore: WorkspaceCustomizationStore? = nil, nativeSSHConnectionBroker: NativeSSHConnectionBroker = NativeSSHConnectionBroker(), agentChatResumeIntentRecorder: any AgentChatResumeIntentRecording = AgentChatTranscriptResumeIntentRecorder(), closeTabWarningDefaults: UserDefaults = .standard, managedDevicePolicy: ManagedDevicePolicy = ManagedDevicePolicy(), fileContentChangeCoordinator: FileContentChangeCoordinator? = nil, cloudWorkspaceSelection: CloudWorkspaceSelectionState? = nil, sshClipboardWriteTrustStore: SSHClipboardWriteTrustStore? = nil ) { let tabDragTransferRegistry = tabDragTransferRegistry ?? TabDragTransferRegistry() self.managedDevicePolicy = managedDevicePolicy self.sshClipboardWriteTrustStore = sshClipboardWriteTrustStore ?? SSHClipboardWriteTrustStore(defaults: .standard) self.cloudWorkspaceSelection = cloudWorkspaceSelection ?? CloudWorkspaceSelectionState(scopeProvider: { nil }) self.settings = settings self.defaultWorkspaceWorkingDirectoryProvider = defaultWorkspaceWorkingDirectoryProvider self.ghosttyWorkingDirectoryInheritanceProvider = ghosttyWorkingDirectoryInheritanceProvider self.workspaceCustomizationStore = workspaceCustomizationStore ?? WorkspaceCustomizationStore() let focusHistoryScopeKey = SettingCatalog().app.focusHistoryIncludesPanesAndTabs self.lastFocusHistoryIncludesPanesAndTabs = settings.value(for: focusHistoryScopeKey) self.focusHistoryNavigation = FocusHistoryModel( now: focusHistoryNow, navigationScope: { settings.value(for: focusHistoryScopeKey) ? .panesAndTabs : .workspacesOnly } ) self.nativeSSHConnectionBroker = nativeSSHConnectionBroker self.agentChatResumeIntentRecorder = agentChatResumeIntentRecorder self.panelTitleUpdateCoalescer = panelTitleUpdateCoalescer ?? NotificationBurstCoalescer() self.windowTitleWriter = windowTitleWriter ?? WindowTitleWriter() self.closeTabWarningDefaults = closeTabWarningDefaults self.tabDragTransferRegistry = tabDragTransferRegistry self.fileContentChangeCoordinator = fileContentChangeCoordinator ?? FileContentChangeCoordinator() workspaceReordering = WorkspaceReorderCoordinator(model: workspaces) workspaceGrouping = WorkspaceGroupCoordinator(model: workspaces) #if DEBUG let isTitleUpdateCoalescingEnabled = PanelTitleUpdateCoalescingSettings.isEnabled(settings: settings) let areTitleUpdateDiagnosticsEnabled = PanelTitleUpdateCoalescingSettings.diagnosticsEnabled(settings: settings) if isTitleUpdateCoalescingEnabled || areTitleUpdateDiagnosticsEnabled { cmuxDebugLog( "workspace.title.coalescing.config enabled=\(isTitleUpdateCoalescingEnabled ? 1 : 0) " + "delayMs=\(PanelTitleUpdateCoalescingSettings.configuredDelayMilliseconds(settings: settings)) " + "diagnostics=\(areTitleUpdateDiagnosticsEnabled ? 1 : 0)" ) } #endif #if DEBUG let sidebarGitDebugLog: @Sendable (String) -> Void = { cmuxDebugLog($0) } #else let sidebarGitDebugLog: @Sendable (String) -> Void = { _ in } #endif let pullRequestProbeService = pullRequestProbeService ?? PullRequestProbeService( commandRunner: commandRunner, debugLog: sidebarGitDebugLog ) self.pullRequestProbeService = pullRequestProbeService let pullRequestPollService = PullRequestPollService( gitMetadataService: gitMetadataService, probeService: pullRequestProbeService, clock: gitPollClock, debugLog: sidebarGitDebugLog ) self.pullRequestProbing = pullRequestPollService self.sidebarGitMetadataService = SidebarGitMetadataService( workspaceGitMetadataReader: workspaceGitMetadataReader ?? gitMetadataService, gitMetadataService: gitMetadataService, pullRequestProbing: pullRequestPollService, probeLimiter: gitProbeLimiter ?? Self.sharedWorkspaceGitProbeLimiter, clock: gitPollClock, debugLog: sidebarGitDebugLog ) // Wire the host seam before the first workspace is added so the // initial git probe scheduling (addWorkspace below) reaches the // services, matching the legacy in-class scheduling timing. pullRequestProbing.attach(host: self) sidebarGitMetadataService.attach(host: self) notificationDismissal.attach(host: self) focusHistoryNavigation.attach(host: self) // Workspace-list/group/selection storage (CmuxWorkspaces). Attached // before the first addWorkspace so the property-observer hooks fire // from the very first insertion, matching the legacy @Published // observer timing. workspaces.attach(host: self) workspaceReordering.attach(host: self) workspaceGrouping.attach(host: self) // The SwiftUI app root needs a command-routing fallback before AppKit // registers the real per-window manager. It must not create a terminal: // SwiftUI may initialize the app value more than once during launch. if createInitialWorkspace { addInitialWorkspaceAssumingActive( title: initialWorkspaceTitle, titleSource: .auto, workingDirectory: initialWorkingDirectory, initialTerminalInput: initialTerminalInput, autoWelcomeIfNeeded: autoWelcomeIfNeeded ) } observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidSetTitle, object: nil, queue: .main ) { [weak self] notification in MainActor.assumeIsolated { [weak self] in guard let self else { return } guard let change = GhosttyTitleChange(notification: notification) else { return } if let workspace = workspacesById[change.tabId], workspace.owningTabManager === self { if let terminal = workspace.terminalPanel(for: change.surfaceId) { let sourceSurface = (notification.object as? TerminalSurface) ?? terminal.surface if change.matches( sourceSurface: sourceSurface, terminalLifecycleID: sourceSurface.terminalLifecycleId ), terminal.surface === sourceSurface { enqueuePanelTitleUpdate(change, sourceSurface: sourceSurface) return } } } _ = dockSplitStore( ownerID: change.tabId, containingPanel: change.surfaceId )?.applyTerminalTitleChange(change) } }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidFocusSurface, object: nil, queue: .main ) { [weak self] notification in MainActor.assumeIsolated { [weak self] in guard let self else { return } guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } let explicitFocusIntent = notification.userInfo?[GhosttyNotificationKey.explicitFocusIntent] as? Bool ?? false let panelId = panelIdForFocusHistorySurface(surfaceId, workspaceId: tabId) if selectedTabId == tabId { if explicitFocusIntent { focusHistoryNavigation.recordFocusInHistory( workspaceId: tabId, panelId: panelId, preservingForwardBranch: false ) } else { focusHistoryNavigation.recordImplicitFocusInHistory(workspaceId: tabId, panelId: panelId) } } dismissPanelNotificationOnFocus( tabId: tabId, panelId: surfaceId, explicitFocusIntent: explicitFocusIntent ) focusedSurfaceTitleDidChange(tabId: tabId) } }) observers.append(NotificationCenter.default.addObserver( forName: .workspaceCurrentDirectoryDidChange, object: nil, queue: .main ) { [weak self] notification in MainActor.assumeIsolated { [weak self] in guard let self else { return } let workspaceId = notification.userInfo?["workspaceId"] as? UUID ?? (notification.object as? Workspace)?.id guard let workspaceId else { return } workspaceCurrentDirectoryDidChange(workspaceId: workspaceId) } }) startAgentPIDSweepTimer() observers.append(NotificationCenter.default.addUserDefaultsObserver(object: nil) { [weak self] in MainActor.assumeIsolated { [weak self] in self?.sidebarMetadataSettingsDidChange() self?.focusHistoryScopeSettingsDidChange() self?.refreshTabCloseButtonVisibility() self?.refreshTabBarVisibility() self?.refreshWindowTitle() } }) #if DEBUG setupUITestFocusShortcutsIfNeeded() setupSplitCloseRightUITestIfNeeded() setupChildExitSplitUITestIfNeeded() setupChildExitKeyboardUITestIfNeeded() #endif } deinit { for observer in observers { NotificationCenter.default.removeObserver(observer) } observers.removeAll() agentPIDSweepTimer?.cancel() // The sidebar git/PR services cancel their own poll, probe, snapshot, // and refresh tasks in their deinits; they deallocate with this // TabManager (the host back-references are weak). } // MARK: - Agent PID Sweep /// Periodically checks agent PIDs associated with status entries. /// If a process has exited (SIGKILL, crash, etc.), clears the stale status entry. /// This is the safety net for cases where no hook fires (e.g. SIGKILL). private func startAgentPIDSweepTimer() { let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) timer.schedule(deadline: .now() + 30, repeating: 30) timer.setEventHandler { [weak self] in guard let self else { return } DispatchQueue.main.async { [weak self] in guard let self else { return } self.sweepStaleAgentPIDs() } } timer.resume() agentPIDSweepTimer = timer } // MARK: - Sidebar git/PR forwarders (subsystem extracted to CmuxSidebarGit) private func sidebarMetadataSettingsDidChange() { sidebarGitMetadataService.sidebarGitMetadataWatchSettingsDidChange() pullRequestProbing.sidebarPullRequestPollingSettingsDidChange() refreshRemotePortScanningEnablement() } private func focusHistoryScopeSettingsDidChange() { let includesPanesAndTabs = settings.value(for: settingsCatalog.app.focusHistoryIncludesPanesAndTabs) guard includesPanesAndTabs != lastFocusHistoryIncludesPanesAndTabs else { return } lastFocusHistoryIncludesPanesAndTabs = includesPanesAndTabs focusHistoryRevisionDidChange() } /// Last ports-visibility enablement fanned out to remote sessions; gates /// the `UserDefaults.didChangeNotification` firehose to actual transitions. private var lastRemotePortScanningEnabled: Bool? /// Propagates the sidebar ports-visibility settings to the local scanner and /// to every live remote session, so that disabling `sidebar.showPorts` (or /// enabling `sidebar.hideAllDetails`) actually stops the scans, not just the /// sidebar display (issue #6123). New remote workspaces pick up the current /// value at creation, so this only needs to react to a change for /// already-connected sessions. private func refreshRemotePortScanningEnablement() { let enabled = Workspace.remotePortScanningEnabledFromSettings() guard enabled != lastRemotePortScanningEnabled else { return } lastRemotePortScanningEnabled = enabled PortScanner.shared.setScanningEnabled(enabled) for tab in tabs where tab.isRemoteWorkspace { tab.applyRemotePortScanningEnabled(enabled) } } func refreshTrackedWorkspaceGitMetadataForTesting() { sidebarGitMetadataService.refreshTrackedWorkspaceGitMetadata(reason: "test") } func sidebarGitMetadataWatchSettingsDidChangeForTesting() { sidebarMetadataSettingsDidChange() } func trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: UUID) -> Set { sidebarGitMetadataService.trackedWorkspaceGitMetadataPollCandidatePanelIds(workspaceId: workspaceId) } func activeWorkspaceGitProbePanelIdsForTesting(workspaceId: UUID) -> Set { sidebarGitMetadataService.activeWorkspaceGitProbePanelIds(workspaceId: workspaceId) } func workspacePullRequestTrackedPanelIdsForTesting(workspaceId: UUID) -> Set { pullRequestProbing.workspacePullRequestTrackedPanelIds(workspaceId: workspaceId) } private func sweepStaleAgentPIDs() { for tab in tabs { tab.clearStaleAgentPIDs() } } func gitProbeDirectory(for workspace: Workspace, panelId: UUID) -> String? { // Cloud cwd belongs to another machine and is never a local git/gh probe target. if workspace.cloudDirectoryProvenanceRequired(panelId: panelId) { return nil } if let directory = workspace.reportedPanelDirectory(panelId: panelId) { return normalizedWorkingDirectory(directory) } guard workspace.allowsLocalDirectoryFallback(panelId: panelId) else { return nil } let rawDirectory = workspace.terminalPanel(for: panelId)?.requestedWorkingDirectory ?? (!workspace.usesRemoteDirectoryProvenance && workspace.focusedPanelId == panelId ? workspace.currentDirectory : nil) return rawDirectory.flatMap(normalizedWorkingDirectory) } func scheduleInitialWorkspaceGitMetadataRefreshIfPossible( workspaceId: UUID, panelId: UUID, reason: String = "initial" ) { #if DEBUG didScheduleInitialWorkspaceGitMetadataRefreshForTesting( workspaceId: workspaceId, panelId: panelId, reason: reason ) #endif sidebarGitMetadataService.scheduleInitialWorkspaceGitMetadataRefreshIfPossible( workspaceId: workspaceId, panelId: panelId, reason: reason ) } func wireClosedBrowserTracking(for workspace: Workspace) { workspace.onClosedBrowserPanel = { [weak self] snapshot in self?.browserModel.recordClosedBrowserPanel(snapshot) } } private func unwireClosedBrowserTracking(for workspace: Workspace) { workspace.onClosedBrowserPanel = nil } var selectedWorkspace: Workspace? { guard let selectedTabId else { return nil } return workspaces.tab(id: selectedTabId) } // Keep selectedTab as convenience alias var selectedTab: Workspace? { selectedWorkspace } // MARK: - Surface/Panel Compatibility Layer /// Returns the focused terminal surface for the selected workspace var selectedSurface: TerminalSurface? { selectedWorkspace?.focusedTerminalInputTarget()?.panel.surface } /// Returns the focused panel's terminal panel (if it is a terminal) var selectedTerminalPanel: TerminalPanel? { selectedWorkspace?.focusedTerminalInputTarget()?.panel } private var selectedWorkspaceTerminalPanels: [TerminalPanel] { selectedWorkspace?.panels.values.compactMap { $0 as? TerminalPanel } ?? [] } var isFindVisible: Bool { selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil || focusedMarkdownPanel?.searchState != nil || focusedTextFilePreviewPanel?.isTextFindBarVisible == true } var canUseSelectionForFind: Bool { selectedTerminalPanel?.hasSelection() == true || focusedTextFilePreviewPanel?.canUseSelectionForFind == true } @discardableResult func startSearch() -> Bool { if let panel = selectedTerminalPanel { let hadExistingSearch = panel.searchState != nil panel.hostedView.preparePanelFocusIntentForActivation(.findField) let recoveredNeedle = hadExistingSearch ? "" : panel.surface.lastSearchNeedle let handled = startOrFocusTerminalSearch(panel.surface, initialNeedle: recoveredNeedle) { surface in NotificationCenter.default.post( name: .ghosttySearchFocus, object: surface, userInfo: [FindFocusNotificationKey.selectAll: !hadExistingSearch && !recoveredNeedle.isEmpty] ) } #if DEBUG cmuxDebugLog( "find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " + "panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " + "handled=\(handled ? 1 : 0) " + "firstResponder=\(String(describing: panel.surface.uiWindow?.firstResponder))" ) #endif return handled } if let browserPanel = focusedBrowserPanel { browserPanel.startFind() // A diff viewer page owns find in-page; the native bar stays // hidden but the shortcut was handled. return browserPanel.searchState != nil || browserPanel.isDiffViewerFindOwner } if let markdownPanel = focusedMarkdownPanel { markdownPanel.startFind() return markdownPanel.searchState != nil } guard let filePreviewPanel = focusedTextFilePreviewPanel else { return false } return filePreviewPanel.startFind() } func searchSelection() { if let panel = selectedTerminalPanel { if panel.searchState == nil { panel.searchState = TerminalSurface.SearchState() } #if DEBUG cmuxDebugLog( "find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) " + "panel=\(panel.id.uuidString.prefix(5))" ) #endif NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) _ = panel.performBindingAction("search_selection") return } focusedTextFilePreviewPanel?.useSelectionForFind() } func findNext() { if let panel = selectedTerminalPanel { _ = TerminalSearchNavigation.next.perform { panel.performBindingAction($0) } return } if let browserPanel = focusedBrowserPanel { browserPanel.findNext() return } if let markdownPanel = focusedMarkdownPanel { markdownPanel.findNext() return } focusedTextFilePreviewPanel?.findNext() } func findPrevious() { if let panel = selectedTerminalPanel { _ = TerminalSearchNavigation.previous.perform { panel.performBindingAction($0) } return } if let browserPanel = focusedBrowserPanel { browserPanel.findPrevious() return } if let markdownPanel = focusedMarkdownPanel { markdownPanel.findPrevious() return } focusedTextFilePreviewPanel?.findPrevious() } @discardableResult func toggleFocusedTerminalCopyMode() -> Bool { guard let panel = selectedTerminalPanel else { return false } return panel.surface.toggleKeyboardCopyMode() } /// Forwards a single Ctrl-F (`^F`) key press to the focused terminal surface, /// faithfully encoded through Ghostty so it matches whatever the running TUI /// would receive from a real keystroke. /// /// This is the non-keyboard escape hatch for control chords that a focused TUI /// reads off the raw tty. The motivating case is Claude Code's force-stop, which /// is only exposed as "press Ctrl-F twice"; invoke this action twice to deliver /// it. Delivery bypasses cmux's shortcut/menu/responder layers entirely. /// /// - Returns: `true` when the chord was sent or queued for the focused terminal, /// `false` when no terminal panel is focused. @discardableResult func sendCtrlFToFocusedTerminal() -> Bool { guard let panel = selectedTerminalPanel else { return false } let result = panel.sendNamedKeyResult("ctrl-f") if result == .sent { panel.surface.forceRefresh(reason: "tabManager.sendCtrlFToFocusedTerminal") } #if DEBUG cmuxDebugLog( "terminal.sendCtrlF workspace=\(panel.workspaceId.uuidString.prefix(5)) " + "panel=\(panel.id.uuidString.prefix(5)) result=\(result)" ) #endif return result.accepted } /// Pastes the newest screenshot's path into the focused terminal through the /// file transfer path (see ``TerminalPanel/pasteLastScreenshot()``). /// /// - Returns: `false` when no terminal panel is focused. A missing /// screenshot is reported later by a beep, after the folder is read. /// Makes the focused shared terminal follow this Mac's window (the shared /// "Size to My Window" action; see ``TerminalSharingStore/sizeToMe(surfaceID:)``). /// /// - Returns: `false` when no terminal is focused or it is not attached. @discardableResult func sizeFocusedTerminalToMyWindow() -> Bool { guard let panel = selectedTerminalPanel else { return false } return TerminalController.shared.terminalSharing.sizeToMe(surfaceID: panel.id) } /// Opens the size panel for the focused terminal. /// /// - Parameter confirmDisconnectOthers: open with the "Disconnect other /// clients" confirmation already showing. /// - Returns: `false` when no terminal is focused. @discardableResult func showFocusedTerminalSizePanel(confirmDisconnectOthers: Bool) -> Bool { guard let panel = selectedTerminalPanel else { return false } return TerminalController.shared.presentTerminalSizePanel( surfaceID: panel.id, confirmDisconnectOthers: confirmDisconnectOthers ) } @discardableResult func pasteLastScreenshotIntoFocusedTerminal() -> Bool { guard let panel = selectedTerminalPanel else { return false } panel.pasteLastScreenshot() return true } @discardableResult func toggleFocusedTerminalTextBox() -> Bool { guard let panel = selectedTerminalPanel else { return false } return panel.toggleTextBoxInput() } /// Clears the focused terminal's visible screen while preserving scrollback. /// /// See `TerminalSurface.clearScreenKeepingScrollback()`. The shared model path /// behind the Cmd+Shift+K shortcut and the "Clear Screen (Keep Scrollback)" /// command palette entry. /// /// - Returns: `true` when a focused terminal performed the clear, `false` when /// no terminal panel is focused. @discardableResult func clearFocusedTerminalKeepingScrollback() -> Bool { guard let panel = selectedTerminalPanel else { return false } let cleared = panel.clearScreenKeepingScrollback() if cleared { panel.surface.forceRefresh(reason: "tabManager.clearFocusedTerminalKeepingScrollback") } return cleared } @discardableResult func focusFocusedTerminalTextBoxInputOrTerminal() -> Bool { guard let panel = selectedTerminalPanel else { return false } return panel.focusTextBoxInputOrTerminal() } @discardableResult func attachFileToFocusedTerminalTextBoxInput() -> Bool { guard let panel = selectedTerminalPanel else { return false } return panel.attachFileToTextBoxInput() } @discardableResult func consumeFocusedTerminalTextBoxHideEscapeIfArmed(in window: NSWindow?) -> Bool { guard let focusedPanel = selectedTerminalPanel else { clearFocusedTerminalTextBoxHideEscapeArm() return false } let consumed = focusedPanel.consumeTextBoxHideEscapeIfArmed(in: window) guard !consumed else { return true } for panel in selectedWorkspaceTerminalPanels { if panel === focusedPanel { continue } panel.clearTextBoxHideEscapeArm() } return false } func clearFocusedTerminalTextBoxHideEscapeArm() { for panel in selectedWorkspaceTerminalPanels { panel.clearTextBoxHideEscapeArm() } } func hideFind() { if let panel = selectedTerminalPanel { panel.surface.closeSearchFromExplicitInput() return } if let browserPanel = focusedBrowserPanel { browserPanel.hideFind() return } if let markdownPanel = focusedMarkdownPanel { markdownPanel.hideFind() return } focusedTextFilePreviewPanel?.hideFind() } func makeWorkspaceForCreation( id: UUID? = nil, title: String, workingDirectory: String?, portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, initialSurface: NewWorkspaceInitialSurface = .terminal, initialTerminalCommand: String?, initialTerminalIsRemote: Bool = false, initialTerminalInput: String? = nil, initialTerminalStartupRestoreAgent: SessionRestorableAgentSnapshot? = nil, initialTerminalStartsOnFirstVisit: Bool = false, initialTerminalEnvironment: [String: String], initialBrowserURL: URL? = nil, initialBrowserOmnibarVisible: Bool = true, initialBrowserTransparentBackground: Bool = false, workspaceEnvironment: [String: String] = [:], allowTextBoxFocusDefault: Bool = true ) -> Workspace { Workspace( id: id, title: title, workingDirectory: workingDirectory, portOrdinal: portOrdinal, configTemplate: configTemplate, initialSurface: initialSurface, initialTerminalCommand: initialTerminalCommand, initialTerminalIsRemote: initialTerminalIsRemote, initialTerminalInput: initialTerminalInput, initialTerminalStartupRestoreAgent: initialTerminalStartupRestoreAgent, initialTerminalStartupRestoreCommitOwner: .tabManagerTopology, initialTerminalStartsOnFirstVisit: initialTerminalStartsOnFirstVisit, initialTerminalEnvironment: initialTerminalEnvironment, initialBrowserURL: initialBrowserURL, initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, workspaceEnvironment: workspaceEnvironment, allowTextBoxFocusDefault: allowTextBoxFocusDefault, tabDragTransferRegistry: tabDragTransferRegistry, settings: settings, sshClipboardWriteTrustStore: sshClipboardWriteTrustStore, closeTabWarningDefaults: closeTabWarningDefaults, agentChatResumeIntentRecorder: agentChatResumeIntentRecorder, fileContentChangeCoordinator: fileContentChangeCoordinator, nativeSSHConnectionBroker: nativeSSHConnectionBroker ) } func makeWorkspaceForDetachedSurface( title: String, workingDirectory: String?, portOrdinal: Int, configTemplate: CmuxSurfaceConfigTemplate?, detachedSurface: Workspace.DetachedSurfaceTransfer ) -> Workspace { Workspace( title: title, workingDirectory: workingDirectory, portOrdinal: portOrdinal, configTemplate: configTemplate, tabDragTransferRegistry: tabDragTransferRegistry, settings: settings, sshClipboardWriteTrustStore: sshClipboardWriteTrustStore, closeTabWarningDefaults: closeTabWarningDefaults, initialDetachedSurface: detachedSurface, agentChatResumeIntentRecorder: agentChatResumeIntentRecorder, fileContentChangeCoordinator: fileContentChangeCoordinator, nativeSSHConnectionBroker: nativeSSHConnectionBroker ) } func makeWindowDockStore(windowId: UUID) -> DockSplitStore { let store = DockSplitStore( workspaceId: windowId, scope: .global, baseDirectoryProvider: { nil }, remoteBrowserSettingsProvider: { .local }, tabDragTransferRegistry: tabDragTransferRegistry, settings: settings, agentChatResumeIntentRecorder: agentChatResumeIntentRecorder, fileContentChangeCoordinator: fileContentChangeCoordinator ) windowDockTitleRoutingStores.setObject( store, forKey: windowId as NSUUID ) return store } /// Resolves a Dock panel through the owner's keyed store instead of the /// process-wide weak store collection. `ownerID` is either a workspace id /// or a window-Dock id; containment rejects stale owner bindings and panel /// ids that have already moved elsewhere. func dockSplitStore( ownerID: UUID, containingPanel panelID: UUID ) -> DockSplitStore? { if let workspace = workspacesById[ownerID], workspace.owningTabManager === self, let store = workspace._dockSplit, store.containsPanel(panelID) { return store } guard let store = windowDockTitleRoutingStores.object( forKey: ownerID as NSUUID ), store.containsPanel(panelID) else { return nil } return store } func applyCreationChromeInheritance( to newWorkspace: Workspace, from sourceWorkspace: Workspace? ) { // Sidebar-toggle relayout updates the live Bonsplit leading inset so minimal-mode // workspaces reserve traffic-light space. New workspaces need that same inset // copied immediately because creation itself does not trigger the resync path. let inheritedLeadingInset = currentWindowTabBarLeadingInset ?? sourceWorkspace?.bonsplitController.configuration.appearance.tabBarLeadingInset guard let inheritedLeadingInset else { return } applyTabBarLeadingInset(inheritedLeadingInset, to: newWorkspace) } func syncWorkspaceTabBarLeadingInset(_ inset: CGFloat) { let normalizedInset = max(0, inset) currentWindowTabBarLeadingInset = normalizedInset for tab in tabs { applyTabBarLeadingInset(normalizedInset, to: tab) } } private func applyTabBarLeadingInset(_ inset: CGFloat, to workspace: Workspace) { if workspace.bonsplitController.configuration.appearance.tabBarLeadingInset != inset { workspace.bonsplitController.configuration.appearance.tabBarLeadingInset = inset } } /// Test seam for mutating live workspace state after the creation snapshot is captured. func didCaptureWorkspaceCreationSnapshot() {} #if DEBUG /// Test seam: invoked when an initial workspace git-metadata refresh is /// scheduled, so tests can observe scheduling without the network probe. func didScheduleInitialWorkspaceGitMetadataRefreshForTesting( workspaceId: UUID, panelId: UUID, reason: String ) {} #endif #if DEBUG func maybeMutateSelectionDuringWorkspaceCreationForDev( snapshot: WorkspaceCreationSnapshot ) { let env = ProcessInfo.processInfo.environment let isEnabled: Bool = { if let raw = env["CMUX_DEV_MUTATE_WORKSPACE_SELECTION_DURING_CREATION"] { return raw == "1" || raw.caseInsensitiveCompare("true") == .orderedSame } return UserDefaults.standard.bool(forKey: "cmuxDevMutateWorkspaceSelectionDuringCreation") }() guard isEnabled, let selectedTabId = snapshot.selectedTabId, let targetId = snapshot.tabs.lazy.map(\.id).first(where: { $0 != selectedTabId }), tabs.contains(where: { $0.id == targetId }) else { return } cmuxDebugLog( "workspace.create.devSelectionMutation from=\(selectedTabId.uuidString.prefix(5)) " + "to=\(targetId.uuidString.prefix(5))" ) self.selectedTabId = targetId } #endif /// Runs workspace acquisition only while this window manager still owns runtime work. func acquireWorkspaceIfActive( _ acquisition: () throws -> Result ) rethrows -> Result? { guard !isFinalizedForWindowClose else { return nil } return try acquisition() } /// Flattens optional workspace acquisitions so callers do not accidentally /// treat a rejected direct creation as a successful nested optional. func acquireOptionalWorkspaceIfActive( _ acquisition: () throws -> Result? ) rethrows -> Result? { guard !isFinalizedForWindowClose else { return nil } return try acquisition() } private func addInitialWorkspaceAssumingActive( title: String?, titleSource: Workspace.CustomTitleSource, workingDirectory: String?, initialTerminalInput: String?, autoWelcomeIfNeeded: Bool ) { precondition( !isFinalizedForWindowClose, "Initial workspace creation requires an active window manager" ) guard addWorkspaceIfActive( title: title, titleSource: titleSource, workingDirectory: workingDirectory, initialTerminalInput: initialTerminalInput, autoWelcomeIfNeeded: autoWelcomeIfNeeded ) != nil else { preconditionFailure("Initial workspace creation failed for an active window manager") } } @discardableResult func addWorkspaceIfActive( id: UUID? = nil, title: String? = nil, titleSource: Workspace.CustomTitleSource = .user, workingDirectory overrideWorkingDirectory: String? = nil, initialSurface: NewWorkspaceInitialSurface = .terminal, initialTerminalCommand: String? = nil, initialTerminalIsRemote: Bool = false, initialTerminalInput: String? = nil, initialTerminalStartupRestoreAgent: SessionRestorableAgentSnapshot? = nil, initialTerminalEnvironment: [String: String] = [:], initialBrowserURL: URL? = nil, initialBrowserOmnibarVisible: Bool = true, initialBrowserTransparentBackground: Bool = false, workspaceEnvironment: [String: String] = [:], inheritWorkingDirectory: Bool = true, select: Bool = true, eagerLoadTerminal: Bool = false, initialTerminalStartsOnFirstVisit: Bool = false, placementOverride: WorkspacePlacement? = nil, autoWelcomeIfNeeded: Bool = true, autoRefreshMetadata: Bool = true, normalizeWorkspaceGroupsAfterInsert: Bool = true, applyCreationTitleAsCustomTitle: Bool = true, allowTextBoxFocusDefault: Bool = true ) -> Workspace? { guard !isFinalizedForWindowClose else { return nil } let sourceWorkspace = selectedWorkspace let capturedTabs = tabs // Snapshot the selected tab from the pinned workspace instead of rereading the // @Published selectedTabId storage after the inheritance helpers. The arm64 Nightly // Cmd+N crash is in PublishedSubject.value.getter on that second getter read. let capturedSelectedTabId = sourceWorkspace?.id // Keep both the source workspace and the pre-creation workspace array alive for the // entire creation path. Release ARC can otherwise drop retains early across the // helper/insertion chain, which reintroduces use-after-free crashes in optimized builds. return withExtendedLifetime((capturedTabs, sourceWorkspace)) { let inheritanceEnabled = inheritWorkingDirectory && settings.value(for: settingsCatalog.app.workspaceInheritWorkingDirectory) && ghosttyWorkingDirectoryInheritance().window let inheritedWorkingDirectory = inheritanceEnabled ? preferredWorkingDirectoryForNewTab(workspace: sourceWorkspace) : nil let fontSizeLineage = inheritedTerminalFontSizeLineageForNewWorkspace( workspace: sourceWorkspace ) let snapshot = workspaceCreationSnapshotLite( currentTabs: capturedTabs, currentSelectedTabId: capturedSelectedTabId, preferredWorkingDirectory: inheritedWorkingDirectory, inheritedTerminalFontSizeLineage: fontSizeLineage ) didCaptureWorkspaceCreationSnapshot() #if DEBUG maybeMutateSelectionDuringWorkspaceCreationForDev(snapshot: snapshot) #endif let nextTabCount = snapshot.tabs.count + 1 sentryBreadcrumb("workspace.create", data: ["tabCount": nextTabCount]) let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) let workingDirectory = WorkspaceCreationWorkingDirectoryPolicy( inheritanceEnabled: inheritanceEnabled ).resolve( explicitWorkingDirectory: explicitWorkingDirectory, inheritedWorkingDirectory: snapshot.preferredWorkingDirectory, defaultWorkingDirectory: defaultWorkspaceWorkingDirectoryProvider() ) let inheritedConfig = workspaceCreationConfigTemplate( inheritedTerminalFontSizeLineage: snapshot.inheritedTerminalFontSizeLineage ) // Resolve placement against the pre-creation snapshot before Workspace init // boots terminal state. The ssh/new-workspace path can otherwise crash while // reading @Published placement state from existing workspaces mid-creation. let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride) var welcomeDelivery: WelcomeBannerDelivery? var resolvedInitialTerminalEnvironment = initialTerminalEnvironment // An explicit command runs via `shell -lc`, which never loads cmux's // interactive shell integration, so it could not print the banner. let hasInitialTerminalCommand = !(initialTerminalCommand? .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) if autoWelcomeIfNeeded && select && initialSurface == .terminal && !hasInitialTerminalCommand && !UserDefaults.standard.bool(forKey: AccountCatalogSection().welcomeShown.userDefaultsKey) { let launch = WelcomeBannerDelivery.prepareLaunch(welcomeBannerDeliveryResolver()) welcomeDelivery = launch.delivery if launch.delivery == .shellStartup { resolvedInitialTerminalEnvironment.merge(launch.environment, uniquingKeysWith: { current, _ in current }) // The token file makes shell-startup delivery at most once, // so mark it shown now; the typed fallback marks on send. UserDefaults.standard.set(true, forKey: AccountCatalogSection().welcomeShown.userDefaultsKey) } } let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 let defaultTitle: String switch initialSurface { case .terminal: defaultTitle = "Terminal \(nextTabCount)" case .browser: // Match the browser surface's blank new-tab title; the // single-panel title sync keeps the workspace title following // the page title once the user navigates. defaultTitle = String(localized: "browser.newTab", defaultValue: "New tab") case .cloudVMLoading: defaultTitle = String(localized: "workspace.cloudVM.defaultTitle", defaultValue: "Cloud VM") } let newWorkspace = makeWorkspaceForCreation( id: id, title: title ?? defaultTitle, workingDirectory: workingDirectory, portOrdinal: ordinal, configTemplate: inheritedConfig, initialSurface: initialSurface, initialTerminalCommand: initialTerminalCommand, initialTerminalIsRemote: initialTerminalIsRemote, initialTerminalInput: initialTerminalInput, initialTerminalStartupRestoreAgent: initialTerminalStartupRestoreAgent, initialTerminalStartsOnFirstVisit: initialTerminalStartsOnFirstVisit, initialTerminalEnvironment: resolvedInitialTerminalEnvironment, initialBrowserURL: initialBrowserURL, initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, workspaceEnvironment: workspaceEnvironment, allowTextBoxFocusDefault: select && allowTextBoxFocusDefault ) applyCreationChromeInheritance( to: newWorkspace, from: sourceWorkspace ?? capturedTabs.first ) newWorkspace.owningTabManager = self if applyCreationTitleAsCustomTitle { applyCreationWorkspaceCustomization( to: newWorkspace, explicitTitle: title, explicitTitleSource: titleSource ) } wireClosedBrowserTracking(for: newWorkspace) if eagerLoadTerminal && !select { requestBackgroundWorkspaceLoad(for: newWorkspace.id) } // Apply insertion to the current live array so post-snapshot closes/reorders // are preserved instead of reintroducing stale workspace instances. var updatedTabs = tabs if insertIndex >= 0 && insertIndex <= updatedTabs.count { updatedTabs.insert(newWorkspace, at: insertIndex) } else { updatedTabs.append(newWorkspace) } tabs = updatedTabs if initialTerminalStartupRestoreAgent != nil { newWorkspace.terminalStartupRestoreCoordinator.commitPendingRestores() } // The global insertion-index rules don't know about group sections. // Re-run the group-aware normalize so a freshly-added workspace // can't land inside another group's contiguous section. if normalizeWorkspaceGroupsAfterInsert, !workspaceGroups.isEmpty { workspaces.normalizeWorkspaceGroupContiguity() } if autoRefreshMetadata, let terminalPanel = newWorkspace.focusedTerminalPanel { scheduleInitialWorkspaceGitMetadataRefreshIfPossible( workspaceId: newWorkspace.id, panelId: terminalPanel.id ) } if eagerLoadTerminal { if select { newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() } } publishCmuxWorkspaceCreated(newWorkspace, selected: select) publishCmuxInitialSurfaceCreated(newWorkspace, selected: select) if select { #if DEBUG debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id) #endif selectedTabId = newWorkspace.id NotificationCenter.default.post( name: .ghosttyDidFocusTab, object: nil, userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id] ) } #if DEBUG UITestRecorder.incrementInt("addTabInvocations") UITestRecorder.record([ "tabCount": String(updatedTabs.count), "selectedTabId": select ? newWorkspace.id.uuidString : (snapshot.selectedTabId?.uuidString ?? "") ]) #endif if welcomeDelivery == .typedCommand { typedWelcomeSender(newWorkspace) } return newWorkspace } } @MainActor private func sendWelcomeWhenReady(to workspace: Workspace) { if let terminalPanel = workspace.focusedTerminalPanel, terminalPanel.surface.surface != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { UserDefaults.standard.set(true, forKey: AccountCatalogSection().welcomeShown.userDefaultsKey) terminalPanel.sendText(WelcomeBannerDelivery.typedCommandText) } return } var resolved = false var readyObserver: NSObjectProtocol? var panelsCancellable: AnyCancellable? func finishIfReady() { guard !resolved, let terminalPanel = workspace.focusedTerminalPanel, terminalPanel.surface.surface != nil else { return } resolved = true if let readyObserver { NotificationCenter.default.removeObserver(readyObserver) } panelsCancellable?.cancel() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { UserDefaults.standard.set(true, forKey: AccountCatalogSection().welcomeShown.userDefaultsKey) terminalPanel.sendText(WelcomeBannerDelivery.typedCommandText) } } panelsCancellable = workspace.panelsPublisher .map { _ in () } .sink { _ in Task { @MainActor in finishIfReady() } } readyObserver = NotificationCenter.default.addObserver( forName: .terminalSurfaceDidBecomeReady, object: nil, queue: .main ) { note in guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, workspaceId == workspace.id else { return } Task { @MainActor in finishIfReady() } } DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { Task { @MainActor in if let readyObserver, !resolved { NotificationCenter.default.removeObserver(readyObserver) } if !resolved { panelsCancellable?.cancel() } } } } func requestBackgroundWorkspaceLoad(for workspaceId: UUID) { guard !pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return } var updated = pendingBackgroundWorkspaceLoadIds updated.insert(workspaceId) pendingBackgroundWorkspaceLoadIds = updated } func completeBackgroundWorkspaceLoad(for workspaceId: UUID) { guard pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return } var updated = pendingBackgroundWorkspaceLoadIds updated.remove(workspaceId) pendingBackgroundWorkspaceLoadIds = updated releaseBackgroundWorkspaceMount(for: workspaceId) } func retainBackgroundWorkspaceMount(for workspaceId: UUID) { guard shouldRetainBackgroundWorkspaceMount(for: workspaceId) else { return } var updated = mountedBackgroundWorkspaceLoadIds updated.insert(workspaceId) mountedBackgroundWorkspaceLoadIds = updated } func releaseBackgroundWorkspaceMount(for workspaceId: UUID) { guard mountedBackgroundWorkspaceLoadIds.contains(workspaceId) else { return } var updated = mountedBackgroundWorkspaceLoadIds updated.remove(workspaceId) mountedBackgroundWorkspaceLoadIds = updated } func retainDebugWorkspaceLoads(for workspaceIds: Set) { guard !workspaceIds.isEmpty else { return } var updated = debugPinnedWorkspaceLoadIds updated.formUnion(workspaceIds) guard updated != debugPinnedWorkspaceLoadIds else { return } debugPinnedWorkspaceLoadIds = updated } func releaseDebugWorkspaceLoads(for workspaceIds: Set) { guard !workspaceIds.isEmpty else { return } var updated = debugPinnedWorkspaceLoadIds updated.subtract(workspaceIds) guard updated != debugPinnedWorkspaceLoadIds else { return } debugPinnedWorkspaceLoadIds = updated } func pruneBackgroundWorkspaceLoads(existingIds: Set) { let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds) if pruned != pendingBackgroundWorkspaceLoadIds { pendingBackgroundWorkspaceLoadIds = pruned } let mounted = mountedBackgroundWorkspaceLoadIds.intersection(existingIds) if mounted != mountedBackgroundWorkspaceLoadIds { mountedBackgroundWorkspaceLoadIds = mounted } let retained = debugPinnedWorkspaceLoadIds.intersection(existingIds) if retained != debugPinnedWorkspaceLoadIds { debugPinnedWorkspaceLoadIds = retained } } /// Legacy nonoptional workspace-creation compatibility API. /// /// New callers should use ``addWorkspaceIfActive(...)`` and handle a /// finalized manager explicitly. This spelling remains for existing /// synchronous callers whose contract requires an active window manager; /// violating that contract is a programmer error and traps rather than /// manufacturing a workspace owned by a closed window. @available(*, deprecated, message: "Use addWorkspaceIfActive(...) for lifecycle-safe creation") @discardableResult func addWorkspace( id: UUID? = nil, title: String? = nil, titleSource: Workspace.CustomTitleSource = .user, workingDirectory overrideWorkingDirectory: String? = nil, initialSurface: NewWorkspaceInitialSurface = .terminal, initialTerminalCommand: String? = nil, initialTerminalInput: String? = nil, initialTerminalStartupRestoreAgent: SessionRestorableAgentSnapshot? = nil, initialTerminalEnvironment: [String: String] = [:], initialBrowserURL: URL? = nil, initialBrowserOmnibarVisible: Bool = true, initialBrowserTransparentBackground: Bool = false, workspaceEnvironment: [String: String] = [:], inheritWorkingDirectory: Bool = true, select: Bool = true, eagerLoadTerminal: Bool = false, placementOverride: WorkspacePlacement? = nil, autoWelcomeIfNeeded: Bool = true, autoRefreshMetadata: Bool = true, normalizeWorkspaceGroupsAfterInsert: Bool = true, applyCreationTitleAsCustomTitle: Bool = true, allowTextBoxFocusDefault: Bool = true ) -> Workspace { guard let workspace = addWorkspaceIfActive( id: id, title: title, titleSource: titleSource, workingDirectory: overrideWorkingDirectory, initialSurface: initialSurface, initialTerminalCommand: initialTerminalCommand, initialTerminalInput: initialTerminalInput, initialTerminalStartupRestoreAgent: initialTerminalStartupRestoreAgent, initialTerminalEnvironment: initialTerminalEnvironment, initialBrowserURL: initialBrowserURL, initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, workspaceEnvironment: workspaceEnvironment, inheritWorkingDirectory: inheritWorkingDirectory, select: select, eagerLoadTerminal: eagerLoadTerminal, placementOverride: placementOverride, autoWelcomeIfNeeded: autoWelcomeIfNeeded, autoRefreshMetadata: autoRefreshMetadata, normalizeWorkspaceGroupsAfterInsert: normalizeWorkspaceGroupsAfterInsert, applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle, allowTextBoxFocusDefault: allowTextBoxFocusDefault ) else { preconditionFailure("Legacy addWorkspace requires an active window manager") } return workspace } /// Restores the startup invariant when the workspace collection is empty. @discardableResult func recoverEmptyWorkspaceAfterStartupIfNeeded() -> Bool { guard !isFinalizedForWindowClose, tabs.isEmpty else { return false } return addWorkspaceIfActive() != nil } // Keep addTab as a nontrapping compatibility alias for runtime callers. @discardableResult func addTab(select: Bool = true, eagerLoadTerminal: Bool = false) -> Workspace? { addWorkspaceIfActive(select: select, eagerLoadTerminal: eagerLoadTerminal) } func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? { terminalPanelForWorkspaceConfigInheritanceSource(workspace: selectedWorkspace) } /// Build a snapshot using pre-extracted value-type data. The caller is responsible /// for obtaining `preferredWorkingDirectory` and font-size lineage through /// `self` (where `self.tabs` keeps all Workspace objects alive) so that no local /// Workspace references are needed here. func workspaceCreationSnapshotLite( currentTabs: [Workspace], currentSelectedTabId: UUID?, preferredWorkingDirectory: String?, inheritedTerminalFontSizeLineage: TerminalFontSizeLineage? ) -> WorkspaceCreationSnapshot { var tabSnapshots: [WorkspaceCreationTabSnapshot] = [] tabSnapshots.reserveCapacity(currentTabs.count) for workspace in currentTabs { // Keep each Workspace alive while copying the tiny value snapshot out of it. // The optimized arm64 Nightly build can otherwise over-release during // Collection.map, crashing here in swift_release / snapshot creation. let snapshot = withExtendedLifetime(workspace) { WorkspaceCreationTabSnapshot(workspace: workspace) } tabSnapshots.append(snapshot) } let selectedTabSnapshot = currentSelectedTabId.flatMap { selectedTabId in tabSnapshots.first(where: { $0.id == selectedTabId }) } return WorkspaceCreationSnapshot( tabs: tabSnapshots, selectedTabId: currentSelectedTabId, selectedTabWasPinned: selectedTabSnapshot?.isPinned ?? false, preferredWorkingDirectory: preferredWorkingDirectory, inheritedTerminalFontSizeLineage: inheritedTerminalFontSizeLineage ) } private func workspaceCreationSnapshot() -> WorkspaceCreationSnapshot { workspaceCreationSnapshotLite( currentTabs: tabs, currentSelectedTabId: selectedTabId, preferredWorkingDirectory: preferredWorkingDirectoryForNewTab(), inheritedTerminalFontSizeLineage: inheritedTerminalFontSizeLineageForNewWorkspace() ) } private func orderedLiveWorkspaceCreationTabs( from snapshot: WorkspaceCreationSnapshot ) -> [WorkspaceCreationTabSnapshot]? { let currentTabs = tabs let snapshotTabsById = Dictionary(uniqueKeysWithValues: snapshot.tabs.map { ($0.id, $0) }) var orderedTabs: [WorkspaceCreationTabSnapshot] = [] orderedTabs.reserveCapacity(currentTabs.count) for workspace in currentTabs { guard let tabSnapshot = snapshotTabsById[workspace.id] else { #if DEBUG cmuxDebugLog( "workspace.create.reentrantSnapshotFallback " + "snapshotCount=\(snapshot.tabs.count) liveCount=\(currentTabs.count)" ) #endif return nil } orderedTabs.append(tabSnapshot) } return orderedTabs } private func terminalPanelForWorkspaceConfigInheritanceSource( workspace: Workspace? ) -> TerminalPanel? { guard let workspace else { return nil } // Prefer cached/published panel state here instead of walking live Bonsplit focus // during Cmd+N; rapid workspace creation can observe transient pane/tab selection. let panels = workspace.panels var candidates: [TerminalPanel] = [] var seen: Set = [] func appendCandidate(_ panel: TerminalPanel?) { guard let panel, seen.insert(panel.id).inserted else { return } candidates.append(panel) } appendCandidate(workspace.lastRememberedTerminalPanelForConfigInheritance()) for terminalPanel in panels.values .compactMap({ $0 as? TerminalPanel }) .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { appendCandidate(terminalPanel) } if let livePanel = candidates.first(where: { $0.surface.hasLiveSurface && $0.surface.surface != nil }) { return livePanel } return candidates.first } private func inheritedTerminalConfigForNewWorkspace() -> CmuxSurfaceConfigTemplate? { inheritedTerminalConfigForNewWorkspace(workspace: selectedWorkspace) } private func cachedInheritedTerminalFontSizeLineageForNewWorkspace( workspace: Workspace? ) -> TerminalFontSizeLineage? { guard let workspace else { return nil } // New workspace creation only seeds font lineage into a fresh Swift-owned template. // Avoid reading live panel/surface state here; the arm64 Nightly Cmd+N crash path // was repeatedly dereferencing pointer-backed terminal objects while preparing the // new workspace. The workspace already caches the rooted font lineage we need. return withExtendedLifetime(workspace) { guard let lineage = workspace.lastRememberedTerminalFontSizeLineageForConfigInheritance(), TerminalFontSizePolicy().acceptsPersistedBasePoints(lineage.basePoints) else { return nil } return lineage } } func inheritedTerminalConfigForNewWorkspace( workspace: Workspace? ) -> CmuxSurfaceConfigTemplate? { guard let fontSizeLineage = cachedInheritedTerminalFontSizeLineageForNewWorkspace( workspace: workspace ) else { return nil } var config = CmuxSurfaceConfigTemplate() config.fontSizeLineage = fontSizeLineage return config } private func inheritedTerminalFontSizeLineageForNewWorkspace() -> TerminalFontSizeLineage? { inheritedTerminalFontSizeLineageForNewWorkspace(workspace: selectedWorkspace) } func inheritedTerminalFontSizeLineageForNewWorkspace( workspace: Workspace? ) -> TerminalFontSizeLineage? { cachedInheritedTerminalFontSizeLineageForNewWorkspace(workspace: workspace) } func workspaceCreationConfigTemplate( inheritedTerminalFontSizeLineage: TerminalFontSizeLineage? ) -> CmuxSurfaceConfigTemplate? { guard let inheritedTerminalFontSizeLineage, TerminalFontSizePolicy().acceptsPersistedBasePoints( inheritedTerminalFontSizeLineage.basePoints ) else { return nil } // Rebuild a clean Swift-owned template instead of carrying over any pointer-backed // inherited config state from the source workspace. var config = CmuxSurfaceConfigTemplate() config.fontSizeLineage = inheritedTerminalFontSizeLineage return config } func normalizedWorkingDirectory(_ directory: String?) -> String? { // Single source of truth: the normalization moved to CmuxSidebarGit // with the git subsystem; non-git callers (workspace creation) keep // this forwarder. directory?.nonEmptyNormalizedGitProbeDirectory } private func newTabInsertIndex(placementOverride: WorkspacePlacement? = nil) -> Int { newTabInsertIndex(snapshot: workspaceCreationSnapshot(), placementOverride: placementOverride) } func newTabInsertIndex( snapshot: WorkspaceCreationSnapshot, placementOverride: WorkspacePlacement? = nil ) -> Int { let placement = WorkspacePlacement.effectivePlacement( placementOverride: placementOverride, settings: settings, catalog: settingsCatalog ) let liveTabs = orderedLiveWorkspaceCreationTabs(from: snapshot) ?? snapshot.tabs let pinnedCount = liveTabs.reduce(into: 0) { partial, tab in if tab.isPinned { partial += 1 } } switch placement { case .top: return pinnedCount case .end: return liveTabs.count case .afterCurrent: if let selectedTabId = snapshot.selectedTabId, let selectedIndex = liveTabs.firstIndex(where: { $0.id == selectedTabId }) { return placement.insertionIndex( selectedIndex: selectedIndex, selectedIsPinned: snapshot.selectedTabWasPinned, pinnedCount: pinnedCount, totalCount: liveTabs.count ) } return snapshot.selectedTabWasPinned ? pinnedCount : liveTabs.count } } private func preferredWorkingDirectoryForNewTab() -> String? { preferredWorkingDirectoryForNewTab(workspace: selectedWorkspace) } func preferredWorkingDirectoryForNewTab( workspace: Workspace? ) -> String? { guard let workspace else { return nil } // Use cached directory state only; avoiding live focus traversal keeps workspace // creation resilient when Bonsplit is in the middle of a rapid Cmd+N churn. if let currentDirectory = normalizedWorkingDirectory(workspace.currentDirectory) { return currentDirectory } return workspace.panelDirectories.values.lazy.compactMap { directory in self.normalizedWorkingDirectory(directory) }.first } /// Ghostty's `*-inherit-working-directory` keys (#177). func ghosttyWorkingDirectoryInheritance() -> GhosttyWorkingDirectoryInheritance { ghosttyWorkingDirectoryInheritanceProvider() } /// The directory a terminal starts in when it does not inherit one: /// Ghostty's `working-directory`, else home. func defaultTerminalWorkingDirectory() -> String { defaultWorkspaceWorkingDirectoryProvider() } func implicitWorkingDirectoryForNewWorkspace(from sourceWorkspace: Workspace?) -> String? { guard settings.value(for: settingsCatalog.app.workspaceInheritWorkingDirectory), ghosttyWorkingDirectoryInheritance().window else { return nil } return preferredWorkingDirectoryForNewTab(workspace: sourceWorkspace) } // MARK: - Reordering (WorkspaceReorderCoordinator, CmuxWorkspaces) func moveTabToTop(_ tabId: UUID) { workspaceReordering.moveTabToTop(tabId) } func moveTabsToTop(_ tabIds: Set) { workspaceReordering.moveTabsToTop(tabIds) } func moveTabToTopForNotification(_ tabId: UUID) { workspaceReordering.moveTabToTopForNotification(tabId) } /// Whether a notification bump would leave this workspace where it is. func isAtTopOfUnpinnedTier(_ tabId: UUID) -> Bool { workspaceReordering.isAtTopOfUnpinnedTier(tabId) } @discardableResult func reorderWorkspace(tabId: UUID, toIndex targetIndex: Int, isDragOperation: Bool = false) -> Bool { let previousMemberships = workspaceGroupMemberships(for: [tabId]) let handled = workspaceReordering.reorderWorkspace( tabId: tabId, toIndex: targetIndex, isDragOperation: isDragOperation ) cleanupGeneratedAnchorsAfterWorkspaceRemoval( previousMemberships: previousMemberships ) return handled } func sidebarReorderWorkspaceIds( forDraggedWorkspaceId draggedWorkspaceId: UUID?, targetWorkspaceId: UUID? = nil, usesTopLevelRows: Bool = false ) -> [UUID] { workspaceReordering.sidebarReorderWorkspaceIds( forDraggedWorkspaceId: draggedWorkspaceId, targetWorkspaceId: targetWorkspaceId, usesTopLevelRows: usesTopLevelRows ) } func sidebarReorderPinnedWorkspaceIds( forDraggedWorkspaceId draggedWorkspaceId: UUID?, targetWorkspaceId: UUID? = nil, usesTopLevelRows: Bool = false ) -> Set { workspaceReordering.sidebarReorderPinnedWorkspaceIds( forDraggedWorkspaceId: draggedWorkspaceId, targetWorkspaceId: targetWorkspaceId, usesTopLevelRows: usesTopLevelRows ) } func sidebarReorderLegalInsertionRange( forDraggedWorkspaceId draggedWorkspaceId: UUID?, targetWorkspaceId: UUID? = nil, usesTopLevelRows: Bool = false, explicitGroupId: UUID? = nil ) -> ClosedRange? { workspaceReordering.sidebarReorderLegalInsertionRange( forDraggedWorkspaceId: draggedWorkspaceId, targetWorkspaceId: targetWorkspaceId, usesTopLevelRows: usesTopLevelRows, explicitGroupId: explicitGroupId ) } @discardableResult func reorderSidebarWorkspace( tabId: UUID, toIndex targetIndex: Int, isDragOperation: Bool = false, usesTopLevelRows: Bool = false, explicitGroupId: UUID? = nil ) -> Bool { let previousMemberships = workspaceGroupMemberships(for: [tabId]) let handled = workspaceReordering.reorderSidebarWorkspace( tabId: tabId, toIndex: targetIndex, isDragOperation: isDragOperation, usesTopLevelRows: usesTopLevelRows, explicitGroupId: explicitGroupId ) cleanupGeneratedAnchorsAfterWorkspaceRemoval( previousMemberships: previousMemberships ) return handled } @discardableResult func reorderSidebarWorkspaces( tabIds: [UUID], draggedTabId: UUID, toIndex targetIndex: Int, isDragOperation: Bool = false, usesTopLevelRows: Bool = false, explicitGroupId: UUID? = nil ) -> Bool { let previousMemberships = workspaceGroupMemberships(for: tabIds) let handled = workspaceReordering.reorderSidebarWorkspaces( tabIds: tabIds, draggedTabId: draggedTabId, toIndex: targetIndex, isDragOperation: isDragOperation, usesTopLevelRows: usesTopLevelRows, explicitGroupId: explicitGroupId ) cleanupGeneratedAnchorsAfterWorkspaceRemoval( previousMemberships: previousMemberships ) return handled } private func workspaceGroupMemberships(for workspaceIds: [UUID]) -> [UUID: UUID] { let requestedIds = Set(workspaceIds) return Dictionary(uniqueKeysWithValues: tabs.compactMap { workspace in guard requestedIds.contains(workspace.id), let groupId = workspace.groupId else { return nil } return (workspace.id, groupId) }) } private func cleanupGeneratedAnchorsAfterWorkspaceRemoval( previousMemberships: [UUID: UUID] ) { let removedMemberships = previousMemberships.filter { workspaceId, groupId in workspacesById[workspaceId]?.groupId != groupId } for groupId in Set(removedMemberships.values) { _ = workspaceGrouping.removeGeneratedAnchorIfOrphaned( groupId: groupId, additionalMovedWorkspaceIds: removedMemberships.compactMap { workspaceId, previousGroupId in previousGroupId == groupId ? workspaceId : nil } ) } } func sidebarReorderUsesTopLevelRows( forDraggedWorkspaceId draggedWorkspaceId: UUID?, targetWorkspaceId: UUID? ) -> Bool { workspaceReordering.sidebarReorderUsesTopLevelRows( forDraggedWorkspaceId: draggedWorkspaceId, targetWorkspaceId: targetWorkspaceId ) } func sidebarReorderUsesTopLevelRows( forDraggedWorkspaceId draggedWorkspaceId: UUID?, targetWorkspaceId: UUID?, workspaceGroupIdByWorkspaceId: [UUID: UUID?] ) -> Bool { workspaceReordering.sidebarReorderUsesTopLevelRows( forDraggedWorkspaceId: draggedWorkspaceId, targetWorkspaceId: targetWorkspaceId, workspaceGroupIdByWorkspaceId: workspaceGroupIdByWorkspaceId ) } func workspaceReorderPlan(tabId: UUID, toIndex targetIndex: Int) -> WorkspaceReorderPlanItem? { workspaceReordering.workspaceReorderPlan(tabId: tabId, toIndex: targetIndex) } /// Legacy `postWorkspaceOrderDidChange`: NotificationCenter + app event /// bus publication (WorkspaceOrderHosting; the reorder/group /// coordinators invert observable order-change publication through /// this hook). func workspaceOrderDidChange(movedWorkspaceIds: [UUID]) { guard !movedWorkspaceIds.isEmpty else { return } NotificationCenter.default.post( name: .workspaceOrderDidChange, object: self, userInfo: [WorkspaceOrderChangeNotificationKey.movedWorkspaceIds: movedWorkspaceIds] ) CmuxEventBus.shared.publishWorkspaceReordered( workspaceIds: tabs.map(\.id), movedWorkspaceIds: movedWorkspaceIds, pinnedWorkspaceIds: tabs.filter(\.isPinned).map(\.id), source: "workspace.lifecycle" ) } @discardableResult func reorderWorkspace(tabId: UUID, before beforeId: UUID? = nil, after afterId: UUID? = nil, isDragOperation: Bool = false) -> Bool { guard let plan = workspaceReorderPlan(tabId: tabId, before: beforeId, after: afterId) else { return false } return reorderWorkspace(tabId: tabId, toIndex: plan.toIndex, isDragOperation: isDragOperation) } func workspaceReorderPlan(tabId: UUID, before beforeId: UUID? = nil, after afterId: UUID? = nil) -> WorkspaceReorderPlanItem? { workspaceReordering.workspaceReorderPlan(tabId: tabId, before: beforeId, after: afterId) } func workspaceBatchReorderPlan( orderedWorkspaceIds: [UUID] ) -> Result<[WorkspaceReorderPlanItem], WorkspaceBatchReorderError> { workspaceReordering.workspaceBatchReorderPlan(orderedWorkspaceIds: orderedWorkspaceIds) } @discardableResult func reorderWorkspaces( orderedWorkspaceIds: [UUID], dryRun: Bool = false ) -> Result<[WorkspaceReorderPlanItem], WorkspaceBatchReorderError> { workspaceReordering.reorderWorkspaces(orderedWorkspaceIds: orderedWorkspaceIds, dryRun: dryRun) } func setCustomDescription(tabId: UUID, description: String?) { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } tabs[index].setCustomDescription(description) } func clearCustomDescription(tabId: UUID) { setCustomDescription(tabId: tabId, description: nil) } func applyWorkspaceColor(_ color: String?, toWorkspaceIds workspaceIds: [UUID]) { guard !workspaceIds.isEmpty else { return } let targetIds = Set(workspaceIds) applyWorkspaceColor(color, to: tabs.filter { targetIds.contains($0.id) }) } func applyWorkspacePaletteColor(named name: String, toWorkspaceIds workspaceIds: [UUID]) { guard let color = WorkspaceTabColorSettings.currentColorHex(named: name) else { return } applyWorkspaceColor(color, toWorkspaceIds: workspaceIds) } func setWorkspaceTerminalScrollBarHidden(tabId: UUID, hidden: Bool) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } tab.setTerminalScrollBarHidden(hidden) } func setWorkspaceTerminalScrollBarHidden(hidden: Bool, forWorkspaceIds workspaceIds: [UUID]) { guard !workspaceIds.isEmpty else { return } if workspaceIds.count == 1, let workspaceId = workspaceIds.first { setWorkspaceTerminalScrollBarHidden(tabId: workspaceId, hidden: hidden) return } let targetIds = Set(workspaceIds) for tab in tabs where targetIds.contains(tab.id) { tab.setTerminalScrollBarHidden(hidden) } } func togglePin(tabId: UUID) { workspaceReordering.togglePin(tabId: tabId) } func setPinned(_ tab: Workspace, pinned: Bool) { workspaceReordering.setPinned(tab, pinned: pinned) } @discardableResult func setPinned(workspaceIds: [UUID], pinned: Bool) -> [UUID] { workspaceReordering.setPinned(workspaceIds: workspaceIds, pinned: pinned) } // MARK: - Workspace Groups (WorkspaceGroupCoordinator, CmuxWorkspaces) @discardableResult func createWorkspaceGroup( name: String, childWorkspaceIds: [UUID] = [], anchorWorkingDirectory: String? = nil, selectAnchor: Bool = true, collapseSidebarSelection: Bool = true, externalID: String? = nil ) -> UUID? { workspaceGrouping.createWorkspaceGroup( name: name, childWorkspaceIds: childWorkspaceIds, anchorWorkingDirectory: anchorWorkingDirectory, selectAnchor: selectAnchor, collapseSidebarSelection: collapseSidebarSelection, externalID: externalID ) } @discardableResult func createWorkspaceInGroup( groupId: UUID, placement explicitPlacement: WorkspaceGroupNewPlacement? = nil, referenceWorkspaceId: UUID? = nil, select: Bool = true, initialSurface: NewWorkspaceInitialSurface = .terminal, title: String? = nil, initialBrowserURL: URL? = nil, initialBrowserOmnibarVisible: Bool = true, initialBrowserTransparentBackground: Bool = false, applyCreationTitleAsCustomTitle: Bool = true, inheritWorkingDirectory: Bool? = nil ) -> Workspace? { workspaceGrouping.createWorkspaceInGroup( groupId: groupId, placement: explicitPlacement, referenceWorkspaceId: referenceWorkspaceId, select: select, initialSurface: initialSurface, title: title, initialBrowserURL: initialBrowserURL, initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle, inheritWorkingDirectory: inheritWorkingDirectory ) } /// Resolves a group's current anchor, including any member promoted after a close. func workspaceGroupAnchor(for groupId: UUID) -> Workspace? { guard let anchorId = workspaceGroups.first(where: { $0.id == groupId })?.anchorWorkspaceId else { return nil } return tabs.first { $0.id == anchorId } } /// Selects a group's current anchor; unlike the plus action, it never creates a workspace. @discardableResult func selectWorkspaceGroupAnchor(for groupId: UUID) -> Workspace? { guard let anchor = workspaceGroupAnchor(for: groupId) else { return nil } selectWorkspace(anchor) return anchor } /// Resolves the target for a group header click. A generated anchor that /// has never received explicit input is only an empty shell, so a header /// click should select the first real member instead of exposing it. func workspaceGroupHeaderTarget(for groupId: UUID) -> Workspace? { guard let group = workspaceGroups.first(where: { $0.id == groupId }), let anchor = workspaceGroupAnchor(for: groupId) else { return nil } guard group.anchorWorkspaceProvenance == .generated, workspaceGroupGeneratedAnchorIsUntouched(anchor) else { return anchor } return tabs.first { $0.groupId == groupId && $0.id != anchor.id } ?? anchor } func addWorkspaceToGroup( workspaceId: UUID, groupId: UUID, placement: WorkspaceGroupNewPlacement? = nil, referenceWorkspaceId: UUID? = nil ) { if let workspace = tabs.first(where: { $0.id == workspaceId }), workspace.isChiefHome, let currentGroup = workspace.groupId, currentGroup != groupId { return } workspaceGrouping.addWorkspaceToGroup( workspaceId: workspaceId, groupId: groupId, placement: placement, referenceWorkspaceId: referenceWorkspaceId ) } func removeWorkspaceFromGroup(workspaceId: UUID) { guard tabs.first(where: { $0.id == workspaceId })?.isChiefHome != true else { return } workspaceGrouping.removeWorkspaceFromGroup(workspaceId: workspaceId) } @discardableResult func ungroupWorkspaceGroup( groupId: UUID, removeGeneratedAnchor: Bool = false ) -> WorkspaceGroupUngroupResult { guard workspaceGroupAnchor(for: groupId)?.isChiefHome != true else { return .emptyPinnedCannotUngroup } return workspaceGrouping.ungroupWorkspaceGroup( groupId: groupId, removeGeneratedAnchor: removeGeneratedAnchor ) } @discardableResult func deleteWorkspaceGroup(groupId: UUID, recordHistory: Bool = true) -> Int { guard !isChiefHomeGroup(groupId) else { return 0 } return workspaceGrouping.deleteWorkspaceGroup(groupId: groupId, recordHistory: recordHistory) } @discardableResult func deleteWorkspaceGroup(confirmed confirmation: WorkspaceGroupDeletionConfirmation) -> Int { guard !isChiefHomeGroup(confirmation.groupId) else { return 0 } return workspaceGrouping.deleteWorkspaceGroup(confirmed: confirmation) } func isChiefHomeGroup(_ groupId: UUID) -> Bool { guard ChiefSettings.isEnabled else { return false } return workspaceGroups.contains { $0.id == groupId && $0.externalID == ChiefHomeNavigation.homeGroupExternalID } || tabs.contains { $0.groupId == groupId && $0.isChiefHome } } func renameWorkspaceGroup(groupId: UUID, name: String) { workspaceGrouping.renameWorkspaceGroup(groupId: groupId, name: name) } func toggleWorkspaceGroupCollapsed(groupId: UUID) { workspaceGrouping.toggleWorkspaceGroupCollapsed(groupId: groupId) } func setWorkspaceGroupCollapsed(groupId: UUID, isCollapsed: Bool) { workspaceGrouping.setWorkspaceGroupCollapsed(groupId: groupId, isCollapsed: isCollapsed) } func toggleWorkspaceGroupPinned(groupId: UUID) { guard workspaceGroupAnchor(for: groupId)?.isChiefHome != true else { return } workspaceGrouping.toggleWorkspaceGroupPinned(groupId: groupId) } /// Pins or unpins a group. Returns false when the change was refused: /// Chief Home's group stays pinned while `chief.enabled` is on. @discardableResult func setWorkspaceGroupPinned(groupId: UUID, isPinned: Bool) -> Bool { guard isPinned || workspaceGroupAnchor(for: groupId)?.isChiefHome != true else { return false } workspaceGrouping.setWorkspaceGroupPinned(groupId: groupId, isPinned: isPinned) return true } /// Why an unpin of Chief Home's group was refused, for socket callers. static var chiefHomeCannotUnpinMessage: String { String( localized: "workspaceGroup.error.chiefHomeCannotUnpin", defaultValue: "The Home group stays pinned while Chief is enabled" ) } func setWorkspaceGroupColor(groupId: UUID, hex: String?) { workspaceGrouping.setWorkspaceGroupColor(groupId: groupId, hex: hex) } @discardableResult func setWorkspaceGroupIcon(groupId: UUID, symbol: String?) -> String? { workspaceGrouping.setWorkspaceGroupIcon(groupId: groupId, symbol: symbol) } func setWorkspaceGroupAnchor(groupId: UUID, workspaceId: UUID) { guard workspaceGroupAnchor(for: groupId)?.isChiefHome != true else { return } workspaceGrouping.setWorkspaceGroupAnchor(groupId: groupId, workspaceId: workspaceId) } func moveWorkspaceGroup(groupId: UUID, toIndex targetIndex: Int) { workspaceGrouping.moveWorkspaceGroup(groupId: groupId, toIndex: targetIndex) } /// Compatibility shim retained for callers that used to ask the legacy /// model to prune empty groups. Pinned empty groups are now durable and /// disappear only through explicit Delete Group. func pruneEmptyWorkspaceGroups() {} // MARK: - WorkspaceGroupHosting (effects the group coordinator inverts) func createGroupAnchorWorkspace( title: String, workingDirectory: String?, inheritWorkingDirectory: Bool, select: Bool ) -> Workspace? { addWorkspaceIfActive( title: title, titleSource: .auto, workingDirectory: workingDirectory, inheritWorkingDirectory: inheritWorkingDirectory, select: select, placementOverride: .top, autoWelcomeIfNeeded: false, normalizeWorkspaceGroupsAfterInsert: false ) } func workspaceGroupGeneratedAnchorIsUntouched(_ anchor: Workspace) -> Bool { guard anchor.customTitle == nil || anchor.customTitleSource == .auto, anchor.customDescription == nil, anchor.panels.count == 1, let panel = anchor.panels.values.first as? TerminalPanel, !panel.hasReceivedExplicitInput, panel.surface.initialCommand == nil, panel.surface.initialInput == nil, panel.surface.tmuxStartCommand == nil, anchor._dockSplit?.panels.isEmpty ?? true else { return false } return true } func createWorkspaceForGroup( title: String?, workingDirectory: String?, initialSurface: NewWorkspaceInitialSurface, initialBrowserURL: URL?, initialBrowserOmnibarVisible: Bool, initialBrowserTransparentBackground: Bool, inheritWorkingDirectory: Bool, select: Bool, applyCreationTitleAsCustomTitle: Bool ) -> Workspace? { addWorkspaceIfActive( title: title, workingDirectory: workingDirectory, initialSurface: initialSurface, initialBrowserURL: initialBrowserURL, initialBrowserOmnibarVisible: initialBrowserOmnibarVisible, initialBrowserTransparentBackground: initialBrowserTransparentBackground, inheritWorkingDirectory: inheritWorkingDirectory, select: select, autoWelcomeIfNeeded: false, applyCreationTitleAsCustomTitle: applyCreationTitleAsCustomTitle ) } func closeWorkspaceForGroupDeletion(_ tab: Workspace, recordHistory: Bool) { closeWorkspace(tab, recordHistory: recordHistory) } func collapseSidebarSelectionForGroupCreation( hiddenWorkspaceIds: Set, anchorId: UUID ) { sidebarMultiSelection.replaceSelection(with: [anchorId]) sidebarMultiSelection.postDidHide(hiddenWorkspaceIds: hiddenWorkspaceIds, focusedWorkspaceId: anchorId) } func subtractSidebarSelection( hiddenWorkspaceIds: Set, focusedWorkspaceId: UUID? ) { sidebarMultiSelection.subtractSelection(hiddenWorkspaceIds) sidebarMultiSelection.postDidHide( hiddenWorkspaceIds: hiddenWorkspaceIds, focusedWorkspaceId: focusedWorkspaceId ) } var localizedAutoGroupNameFormat: String { String( localized: "workspaceGroup.autoName.numbered", defaultValue: "Group %lld" ) } var defaultNewWorkspacePlacementInGroup: WorkspaceGroupNewPlacement { settings.value(for: settingsCatalog.workspaceGroups.newWorkspacePlacement) } func normalizedGroupIconSymbol(_ symbol: String?) -> String? { RenderableSystemSymbol.normalized(symbol) } func workspaceGroupNameDidChange() { updateWindowTitleForSelectedTab() NotificationCenter.default.post(name: .workspaceGroupNameDidChange, object: self) } // MARK: - Surface Directory Updates (Backwards Compatibility) func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String, displayLabel: String? = nil) { sidebarGitMetadataService.updateSurfaceDirectory( workspaceId: tabId, panelId: surfaceId, directory: directory, displayLabel: displayLabel ) } func updateSurfaceGitBranch( tabId: UUID, surfaceId: UUID, branch: String, isDirty: Bool? ) { sidebarGitMetadataService.updateSurfaceGitBranch( workspaceId: tabId, panelId: surfaceId, branch: branch, isDirty: isDirty ) } func clearSurfaceGitBranch(tabId: UUID, surfaceId: UUID) { sidebarGitMetadataService.clearSurfaceGitBranch(workspaceId: tabId, panelId: surfaceId) } func updateSurfaceShellActivity( tabId: UUID, surfaceId: UUID, state: PanelShellActivityState ) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } tab.updatePanelShellActivityState(panelId: surfaceId, state: state) if state == .promptIdle { pullRequestProbing.scheduleWorkspacePullRequestRefresh( workspaceId: tabId, panelId: surfaceId, reason: "shellPrompt" ) } } func handleWorkspacePullRequestCommandHint( tabId: UUID, surfaceId: UUID, action: String, target: String? ) { pullRequestProbing.handleWorkspacePullRequestCommandHint( workspaceId: tabId, panelId: surfaceId, action: action, target: target ) } func closeWorkspace(_ workspace: Workspace, recordHistory: Bool = true) { guard !workspace.isChiefHome, tabs.count > 1 else { return } guard tabs.contains(where: { $0.id == workspace.id }) else { return } if workspace.groupId.flatMap({ id in workspaceGroups.first { $0.id == id }?.externalID }) == ChiefHomeNavigation.homeGroupExternalID { var hidden = Set(UserDefaults.standard.stringArray(forKey: ChiefHomeNavigation.hiddenConversationsKey) ?? []) for panel in workspace.panels.values.compactMap({ $0 as? ChiefPanel }) { if let id = panel.conversationID { hidden.insert(id.rawValue) } } UserDefaults.standard.set(hidden.sorted(), forKey: ChiefHomeNavigation.hiddenConversationsKey) } // Teardown SIGHUPs child processes and publishes a closed event. Only this // manager may close its own live workspaces; stale or foreign objects must // never tear down terminals or publish a second close. guard tabs.contains(where: { $0.id == workspace.id }) else { return } MachineCreateCoordinator.shared.cancelOperations(forPresentationWorkspace: workspace.id) panelTitleUpdateCoalescer.flushNow() sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) // Closing a mirrored remote tmux workspace DETACHES from the remote session, // leaving it alive on the server for resume. Killing the session is never a // side effect of closing a tab (PR #7264 review); it is only ever an explicit // disconnect action. if workspace.isRemoteTmuxMirror { AppDelegate.shared?.remoteTmuxController.detachMirrorWorkspaceKeptOpenLocally(workspaceId: workspace.id) } // Closing the login cmux opened for a reconnect is the user declining it, and that is the // only signal for it — a closed tab is not a filesystem or protocol event, so the waiter // watching for the shared master would never hear about it. if workspace.isRemoteTmuxAuthLogin { AppDelegate.shared?.remoteTmuxController.noteLoginWorkspaceClosed(workspaceId: workspace.id) } if recordHistory, workspace.isRestorableInSessionSnapshot, let index = tabs.firstIndex(where: { $0.id == workspace.id }) { // Prefer the warm cached agent index over a synchronous // RestorableAgentSessionIndex.load() (sysctl-per-record + disk) so closing a // workspace does not freeze the main thread; fall back to a fresh load only // while the cache has not loaded yet. See closedPanelHistoryEntry. let snapshot = workspace.sessionSnapshot( includeScrollback: true, restorableAgentIndex: SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() ?? RestorableAgentSessionIndex.load() ) ClosedItemHistoryStore.shared.push(.workspace(ClosedWorkspaceHistoryEntry( workspaceId: workspace.id, windowId: AppDelegate.shared?.windowId(for: self), workspaceIndex: index, snapshot: snapshot ))) } finalizeWorkspaceForRemoval(workspace) workspaceCustomizationStore.removeCustomization(for: workspace.stableId) if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { tabs.remove(at: index) let closedWorkspaceGroupId = workspace.groupId let closedWorkspaceWasGroupAnchor = closedWorkspaceGroupId.flatMap { groupId in workspaces.workspaceGroups.first(where: { $0.id == groupId })?.liveAnchorWorkspaceId } == workspace.id // Real-close path: if the closed workspace anchored a group, keep // the group by promoting its first remaining member (in tabs order) // to anchor so closing one workspace only closes that workspace and // never scatters the rest of its group out to the ungrouped root // tier. A group with no members left after the anchor's removal is // dropped. This lives at the explicit close site (not in the tabs // didSet) so transient remove/insert reorders never trigger the // fixup. let promotedAnchorIds = workspaces.promoteAnchorOrRemoveGroupsAnchoredBy(closedWorkspaceId: workspace.id) if let closedWorkspaceGroupId, !closedWorkspaceWasGroupAnchor { _ = workspaceGrouping.removeGeneratedAnchorIfOrphaned( groupId: closedWorkspaceGroupId, additionalMovedWorkspaceIds: [workspace.id] ) } if selectedTabId == workspace.id, let nextSelectedId = workspaces.selectionTargetAfterClose(closedIndex: index) { // Keep the "focused row position" stable when possible; see // WorkspacesModel.selectionTargetAfterClose for the rule. selectedTabId = nextSelectedId } // A promoted anchor's resolved display title switches from its own // title to the group name. The imperatively-cached title consumers // (custom titlebar text, WindowToolbarController label, notification // popover titles) refresh on the group-name / order-change // notifications, not on the observable model — and on a non-focused // anchor close (Close Others, a socket close while a member stays // selected) selectedTabId never changes, so nothing would otherwise // invalidate them. Publish both signals for the promotion. if !promotedAnchorIds.isEmpty { workspaceGroupNameDidChange() workspaceOrderDidChange(movedWorkspaceIds: promotedAnchorIds) } } publishCmuxWorkspaceClosed(workspace) } /// Finalizes every workspace owned by a closing window without creating a /// replacement workspace or recording per-workspace closed-item history. func finalizeAllWorkspacesForWindowClose() { guard !isFinalizedForWindowClose else { return } isFinalizedForWindowClose = true let closingWorkspaces = Array(tabs) panelTitleUpdateCoalescer.flushNow() sidebarGitMetadataService.resetAllWorkspaceGitProbeTracking() MachineCreateCoordinator.shared.cancelOperations(forPresentationWorkspaces: Set(closingWorkspaces.map(\.id))) for workspace in closingWorkspaces { finalizeWorkspaceForRemoval(workspace, clearsWorkspaceGitProbes: false) } sidebarMultiSelection.replaceSelection(with: []) pruneBackgroundWorkspaceLoads(existingIds: []) pendingPanelTitleUpdates.removeAll() pendingWorkspaceUnfocusTarget = nil notificationDismissal.setPendingSelectionContext(nil) notificationDismissal.setSuppressesFocusFlash(false) workspaceHandoffRetirementGate.cancel() workspaceSwitchCoordinator.cancel() browserModel.clearRecentlyClosedBrowserPanels() tabs.removeAll() workspaceGroups.removeAll() selectedTabId = nil lastFocusedPanelByTab.removeAll() focusHistoryNavigation.reset() focusHistoryRevision &+= 1 // Invalidate every queued selection effect, including the one emitted // by clearing `selectedTabId` above. selectionSideEffectsGeneration &+= 1 // The window-close transaction is final even if SwiftUI retains this // manager. Stop process-wide observations and periodic work that would // otherwise keep reacting on behalf of a closed window. for observer in observers { NotificationCenter.default.removeObserver(observer) } observers.removeAll() agentPIDSweepTimer?.cancel() agentPIDSweepTimer = nil for workspace in closingWorkspaces { publishCmuxWorkspaceClosed(workspace) } } /// Runs the shared per-workspace ownership cleanup before the manager /// removes the workspace from its collection. private func finalizeWorkspaceForRemoval( _ workspace: Workspace, clearsWorkspaceGitProbes: Bool = true ) { if clearsWorkspaceGitProbes { sidebarGitMetadataService.clearWorkspaceGitProbes(workspaceId: workspace.id) } sidebarMultiSelection.removeFromSelection(workspace.id) invalidateFocusHistoryTarget(workspaceId: workspace.id, panelId: nil) lastFocusedPanelByTab.removeValue(forKey: workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) workspace.withClosedPanelHistorySuppressed { workspace.retireFromOwningTabManager() } unwireClosedBrowserTracking(for: workspace) browserModel.removeClosedBrowserPanels(forWorkspaceId: workspace.id) } /// Detach a workspace from this window without closing its panels. /// Used by the socket API for cross-window moves. @discardableResult func detachWorkspace(tabId: UUID) -> Workspace? { guard let index = tabs.firstIndex(where: { $0.id == tabId }), !tabs[index].isChiefHome else { return nil } panelTitleUpdateCoalescer.flushNow() sidebarGitMetadataService.clearWorkspaceGitProbes(workspaceId: tabId) sidebarMultiSelection.removeFromSelection(tabId) invalidateFocusHistoryTarget(workspaceId: tabId, panelId: nil) let removed = tabs.remove(at: index) let removedWorkspaceGroupId = removed.groupId // Same anchor-close lifecycle as closeWorkspace: an unpinned group's // anchor dissolves it, while a pinned group promotes a remaining member // or retains an empty header. workspaces.dissolveGroupsAnchoredBy(closedWorkspaceId: removed.id) if let removedWorkspaceGroupId { _ = workspaceGrouping.removeGeneratedAnchorIfOrphaned( groupId: removedWorkspaceGroupId, additionalMovedWorkspaceIds: [removed.id] ) } // Clear the detached workspace's own group membership so the // destination window — which has no matching WorkspaceGroup — doesn't // render it as an orphaned indented row with stale grouping state. removed.groupId = nil unwireClosedBrowserTracking(for: removed) browserModel.removeClosedBrowserPanels(forWorkspaceId: removed.id) removed.owningTabManager = nil lastFocusedPanelByTab.removeValue(forKey: removed.id) if recoverEmptyWorkspaceAfterStartupIfNeeded() { return removed } if selectedTabId == removed.id { let nextIndex = min(index, max(0, tabs.count - 1)) selectedTabId = tabs[nextIndex].id } return removed } /// Attach an existing workspace to this window. func attachWorkspace(_ workspace: Workspace, at index: Int? = nil, select: Bool = true) { workspace.owningTabManager = self wireClosedBrowserTracking(for: workspace) let insertIndex: Int = { guard let index else { return tabs.count } return max(0, min(index, tabs.count)) }() tabs.insert(workspace, at: insertIndex) // A workspace moved in from another window arrives ungrouped (detach // clears `groupId`) and may be pinned, so an arbitrary insert index can // split a destination group's contiguous run or drop a pinned workspace // below unpinned ones. Re-run the same normalization every insertion // path uses so the destination's sidebar invariants — leading pinned // segment, contiguous group runs — hold regardless of the drop index. workspaces.normalizeWorkspaceGroupContiguity() if select { selectedTabId = workspace.id } } // Keep closeTab as convenience alias func closeTab(_ tab: Workspace) { closeWorkspace(tab) } func closeCurrentTabWithConfirmation() { closeCurrentWorkspaceWithConfirmation() } func closeCurrentWorkspace() { guard let selectedId = selectedTabId, let workspace = tabs.first(where: { $0.id == selectedId }) else { return } closeWorkspace(workspace) } func closeCurrentPanelWithConfirmation() { #if DEBUG UITestRecorder.incrementInt("closePanelInvocations") #endif guard !closeConfirmationInFlight else { return } guard let selectedId = selectedTabId, let tab = tabs.first(where: { $0.id == selectedId }) else { return } reconcileFocusedPanelFromFirstResponderForKeyboard() guard let focusedPanelId = shortcutCloseTargetPanelId(in: tab) else { return } closePanelWithConfirmation(tab: tab, panelId: focusedPanelId) } func canCloseOtherTabsInFocusedPane() -> Bool { closeOtherTabsInFocusedPanePlan() != nil } func closeOtherTabsInFocusedPaneWithConfirmation() { guard !closeConfirmationInFlight else { return } guard let plan = closeOtherTabsInFocusedPanePlan() else { return } let warningStore = CloseTabWarningStore(defaults: closeTabWarningDefaults) let hasActiveProcess = plan.panelIds.contains { plan.workspace.panelNeedsConfirmClose(panelId: $0) } var warningKinds = warningStore.warningKinds( requiresConfirmation: true, source: .shortcut ) if hasActiveProcess { warningKinds.insert(.safety) } if !warningKinds.isEmpty { let prompt = CloseOtherTabsConfirmationPrompt(titles: plan.titles) guard confirmClose( title: prompt.title, message: prompt.message, scrollableDetails: prompt.details, acceptCmdD: false, dontAskAgain: warningKinds ) else { return } } for panelId in plan.panelIds { plan.workspace.markCloseHistoryEligible(panelId: panelId) _ = plan.workspace.closePanel(panelId, force: true) } } func closeCurrentWorkspaceWithConfirmation() { #if DEBUG UITestRecorder.incrementInt("closeTabInvocations") #endif guard !closeConfirmationInFlight else { return } let sidebarSelectionIds = orderedSidebarSelectedWorkspaceIds() if sidebarSelectionIds.count > 1 { closeWorkspacesWithConfirmation(sidebarSelectionIds, allowPinned: true) return } guard let selectedId = selectedTabId, let workspace = tabs.first(where: { $0.id == selectedId }) else { return } closeWorkspaceWithConfirmation(workspace) } func canCloseWorkspace(_ workspace: Workspace, allowPinned: Bool = false) -> Bool { !workspace.isChiefHome && (allowPinned || !workspace.isPinned) } @discardableResult func closeWorkspaceWithConfirmation(_ workspace: Workspace) -> Bool { guard !workspace.isChiefHome else { return false } if workspace.isPinned { let pinnedConfirmation = confirmPinnedWorkspaceClose(source: .workspace) guard pinnedConfirmation != .cancelled else { return false } return closeWorkspaceIfRunningProcess( workspace, requiresConfirmation: false, closeAlreadyConfirmed: pinnedConfirmation == .confirmed ) } return closeWorkspaceIfRunningProcess(workspace) } @discardableResult func closeWorkspaceFromCloseTabGesture(_ workspace: Workspace) -> Bool { guard !workspace.isChiefHome else { return false } if workspace.isPinned { let pinnedConfirmation = confirmPinnedWorkspaceClose(source: .tabClose) guard pinnedConfirmation != .cancelled else { return false } return closeWorkspaceIfRunningProcess( workspace, requiresConfirmation: false, closeAlreadyConfirmed: pinnedConfirmation == .confirmed ) } return closeWorkspaceIfRunningProcess(workspace, source: .tabClose) } @discardableResult func closeWorkspaceFromTabCloseButton(_ workspace: Workspace) -> Bool { guard !workspace.isChiefHome else { return false } if workspace.isPinned { let pinnedConfirmation = confirmPinnedWorkspaceClose(source: .tabCloseButton) guard pinnedConfirmation != .cancelled else { return false } return closeWorkspaceIfRunningProcess( workspace, requiresConfirmation: false, closeAlreadyConfirmed: pinnedConfirmation == .confirmed ) } return closeWorkspaceIfRunningProcess(workspace, source: .tabCloseButton) } @discardableResult func closeWorkspaceWithConfirmation(tabId: UUID) -> Bool { guard let workspace = tabs.first(where: { $0.id == tabId }) else { return false } return closeWorkspaceWithConfirmation(workspace) } func setSidebarSelectedWorkspaceIds(_ workspaceIds: Set) { let existingIds = Set(tabs.map(\.id)) sidebarMultiSelection.replaceSelection(with: workspaceIds.intersection(existingIds)) } /// No-op: closing a remote-tmux mirror workspace/tab/window must DETACH from the /// remote session, never kill it. Killing a live tmux session is only ever an /// explicit disconnect action, never a side effect of closing a tab (PR #7264 /// review by the ssh-tmux author). This seam formerly set the window /// kill-on-close marker so the close committed a `kill-session`; it is retained /// as a no-op (still called from the last-workspace and batch/anchor close paths) /// so those paths fall through to detach via `AppDelegate`'s window-close handlers /// and the app-quit deferral gate stays empty. The marker machinery is left in /// place for a future explicit "disconnect host" action. func markRemoteTmuxKillOnWindowCloseIfNeeded(for workspaces: [Workspace]) {} func closeWorkspacesWithConfirmation(_ workspaceIds: [UUID], allowPinned: Bool) { let workspaces = orderedClosableWorkspaces(workspaceIds, allowPinned: allowPinned) guard !workspaces.isEmpty else { return } guard workspaces.count > 1 else { closeWorkspaceFromCloseTabGesture(workspaces[0]) return } let plan = closeWorkspacesPlan(for: workspaces) var closeAlreadyConfirmed = false // Members close below without their own prompts, so a batch holding a // pinned workspace keeps the pinned gate instead of the workspace one. // Closing every workspace closes the window, so that "Close window?" // variant follows the window setting. Pinned workspaces never offer // "Don't ask again": no setting may silence the protection pinning // asked for. let containsPinned = plan.workspaces.contains(where: \.isPinned) let windowDockNeedsConfirmation = plan.willCloseWindow && AppDelegate.shared?.existingWindowDock(for: self)?.needsConfirmClose() == true let hasActiveProcess = plan.workspaces.contains(where: workspaceNeedsConfirmClose) || windowDockNeedsConfirmation let alwaysConfirm = CloseTabWarningStore(defaults: closeTabWarningDefaults).alwaysConfirmsWorkspaceClose let showsBatchConfirmation: Bool let dontAskAgain: CloseWarningKinds if containsPinned { showsBatchConfirmation = CloseTabWarningStore(defaults: closeTabWarningDefaults) .shouldConfirmCloseIncludingSafety(requiresConfirmation: true, source: .shortcut) dontAskAgain = [] } else if plan.willCloseWindow { // A batch that closes the whole window follows the window warning // policy. The tab warning must not suppress this prompt. let windowWarns = CloseTabWarningStore(defaults: closeTabWarningDefaults).warnsBeforeClosingWindow || hasActiveProcess showsBatchConfirmation = windowWarns || alwaysConfirm dontAskAgain = Self.workspaceCloseDontAskAgain( ordinary: hasActiveProcess ? [.window, .safety] : .window, ordinaryWarns: windowWarns, alwaysConfirm: alwaysConfirm ) } else { let workspaceWarns = shouldConfirmWorkspaceClose(requiresConfirmation: true, source: .tabClose) || hasActiveProcess showsBatchConfirmation = workspaceWarns || alwaysConfirm dontAskAgain = Self.workspaceCloseDontAskAgain( ordinary: hasActiveProcess ? [.workspace, .safety] : .workspace, ordinaryWarns: workspaceWarns, alwaysConfirm: alwaysConfirm ) } if showsBatchConfirmation { guard confirmClose( title: plan.title, message: plan.message, scrollableDetails: plan.details, acceptCmdD: plan.willCloseWindow, dontAskAgain: dontAskAgain ) else { return } closeAlreadyConfirmed = true } if plan.workspaces.count == tabs.count, let firstWorkspace = plan.workspaces.first { // Closing every tab routes through the window-close path, which DETACHES // the remote-tmux session(s) (kept alive on the server for resume); the // mark seam is a retained no-op (see markRemoteTmuxKillOnWindowCloseIfNeeded). markRemoteTmuxKillOnWindowCloseIfNeeded(for: plan.workspaces) if closeWindowForLastWorkspace( workspaceId: firstWorkspace.id, closeAlreadyConfirmed: closeAlreadyConfirmed ) { return } } // Drain non-anchor members before group anchors (see anchorLastCloseOrder): // closing a group's anchor is no longer destructive to the group (its next // member is promoted to anchor), so batch close needs no special anchor // confirmation or handling here. for workspace in anchorLastCloseOrder(plan.workspaces) { guard tabs.contains(where: { $0.id == workspace.id }) else { continue } _ = closeWorkspaceIfRunningProcess(workspace, requiresConfirmation: false) } } /// Reorders a batch of workspaces so group anchors close after their /// non-anchor members. Closing an anchor promotes the group's next member /// and renormalizes the whole tabs/groups collection; closing in raw tabs /// order (anchor first) would re-promote and rescan once per targeted /// member — O(k x totalTabs) main-actor work plus a burst of title/order /// invalidations. Members-first bounds a batch to at most one promotion per /// group (or none, when the anchor was the group's last surviving member). /// Every batch-close entrypoint (menu, shortcut, socket) must route through /// this ordering. func anchorLastCloseOrder(_ workspaces: [Workspace]) -> [Workspace] { let anchorIds = Set(workspaceGroups.compactMap(\.liveAnchorWorkspaceId)) return workspaces.filter { !anchorIds.contains($0.id) } + workspaces.filter { anchorIds.contains($0.id) } } func selectWorkspace(_ workspace: Workspace) { #if DEBUG debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id) #endif selectWorkspaceId(workspace.id, notificationDismissalContext: .explicitWorkspaceResume) } // Keep selectTab as convenience alias func selectTab(_ tab: Workspace) { selectWorkspace(tab) } var isCloseConfirmationInFlight: Bool { closeConfirmationInFlight } func beginCloseConfirmationSession() -> Bool { guard !closeConfirmationInFlight else { return false } closeConfirmationInFlight = true return true } func endCloseConfirmationSession() { DispatchQueue.main.async { [weak self] in self?.closeConfirmationInFlight = false } } func confirmClose( title: String, message: String, scrollableDetails: String? = nil, acceptCmdD: Bool, dontAskAgain: CloseWarningKinds = [] ) -> Bool { guard beginCloseConfirmationSession() else { return false } defer { endCloseConfirmationSession() } let content = scrollableDetails.map { CmuxAlertContent(flattenedText: message, separatingScrollableDetails: $0) } ?? CmuxAlertContent(informativeText: message) if let confirmCloseHandler { let accepted = confirmCloseHandler(title, content.flattenedText, acceptCmdD) if !dontAskAgain.isEmpty, confirmCloseDontAskAgainHandler?(dontAskAgain) == true { CloseTabWarningStore(defaults: closeTabWarningDefaults).disableWarnings(dontAskAgain) } return accepted } _ = acceptCmdD let alert = NSAlert() alert.messageText = title alert.alertStyle = .warning alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) if let closeButton = alert.buttons.first { closeButton.keyEquivalent = "\r" closeButton.keyEquivalentModifierMask = [] alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell alert.window.initialFirstResponder = closeButton } if let cancelButton = alert.buttons.dropFirst().first { cancelButton.keyEquivalent = "\u{1b}" } #if DEBUG UITestRecorder.record([ "closeConfirmationTitle": title, "closeConfirmationMessage": message, ]) #endif CloseDontAskAgainCheckbox.add(to: alert, offering: dontAskAgain) let accepted = runCloseConfirmationAlert(alert, content: content) == .alertFirstButtonReturn CloseDontAskAgainCheckbox.apply(from: alert, offering: dontAskAgain, defaults: closeTabWarningDefaults) return accepted } private func runCloseConfirmationAlert( _ alert: NSAlert, content: CmuxAlertContent? = nil ) -> NSApplication.ModalResponse { // Presentation (activate + sheet-on-main-window, else app-modal) is // shared with every other cmux dialog via `NSAlert.runCmuxModal`. This // wrapper only adds the close-confirmation-specific UITest telemetry, // recorded from the presenter's actual path so the label can never // disagree with how the alert was really shown. return alert.runCmuxModal( presentingWindow: closeConfirmationPresentingWindow(), content: content ) { presentation in #if DEBUG switch presentation { case .sheet(let hostWindow): // The sheet attaches after this hook returns, so read the // attachment on the next runloop turn (during the modal loop). DispatchQueue.main.async { UITestRecorder.record([ "closeConfirmationPresentation": "sheet", "closeConfirmationAttachedSheet": hostWindow.attachedSheet == nil ? "0" : "1", ]) } case .appModal(let hostWindowHadAttachedSheet): UITestRecorder.record([ "closeConfirmationPresentation": "appModal", "closeConfirmationAttachedSheet": hostWindowHadAttachedSheet ? "1" : "0", ]) } #endif } } private func closeConfirmationPresentingWindow() -> NSWindow? { NSApp.cmuxMainWindowForModalPresentation(preferring: window) } private struct CloseOtherTabsInFocusedPanePlan { let workspace: Workspace let panelIds: [UUID] let titles: [String] } private struct CloseWorkspacesPlan { let workspaces: [Workspace] let title: String let message: String let details: String /// Every workspace in the window is closing, so the window closes too. /// The dialog then accepts Cmd+D like the other window-close prompts. let willCloseWindow: Bool } private enum CloseConfirmationSource { case workspace case tabClose case tabCloseButton } private func closeOtherTabsInFocusedPanePlan() -> CloseOtherTabsInFocusedPanePlan? { guard let workspace = selectedWorkspace else { return nil } guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { return nil } let tabsInPane = workspace.bonsplitController.tabs(inPane: paneId) guard !tabsInPane.isEmpty else { return nil } guard let selectedTabId = workspace.bonsplitController.selectedTab(inPane: paneId)?.id ?? tabsInPane.first?.id else { return nil } var targetPanelIds: [UUID] = [] var targetTitles: [String] = [] for tab in tabsInPane where tab.id != selectedTabId { guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } if workspace.isPanelPinned(panelId) { continue } targetPanelIds.append(panelId) targetTitles.append(CloseOtherTabsConfirmationPrompt.displayTitle(workspace.panelTitle(panelId: panelId))) } guard !targetPanelIds.isEmpty else { return nil } return CloseOtherTabsInFocusedPanePlan( workspace: workspace, panelIds: targetPanelIds, titles: targetTitles ) } private func orderedClosableWorkspaces(_ workspaceIds: [UUID], allowPinned: Bool) -> [Workspace] { let targetIds = Set(workspaceIds) return tabs.compactMap { workspace in guard targetIds.contains(workspace.id) else { return nil } guard allowPinned || !workspace.isPinned else { return nil } return workspace } } private func orderedSidebarSelectedWorkspaceIds() -> [UUID] { tabs.compactMap { workspace in sidebarSelectedWorkspaceIds.contains(workspace.id) ? workspace.id : nil } } private func closeWorkspacesPlan(for workspaces: [Workspace]) -> CloseWorkspacesPlan { let willCloseWindow = workspaces.count == tabs.count let title = willCloseWindow ? String(localized: "dialog.closeWindow.title", defaultValue: "Close window?") : String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?") // Use the resolved display title so a group anchor is listed by the // header name the user sees (the group name), not its underlying // Workspace.title. This matters for a promoted anchor, whose own title // (e.g. "API") diverges from its visible header ("Build"). let titleLines = workspaces .map { "• \(closeWorkspaceDisplayTitle(resolvedWorkspaceDisplayTitle(for: $0)))" } .joined(separator: "\n") let format = willCloseWindow ? String( localized: "dialog.closeWorkspacesWindow.message", defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@" ) : String( localized: "dialog.closeWorkspaces.message", defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@" ) let message = String(format: format, locale: .current, Int64(workspaces.count), titleLines) return CloseWorkspacesPlan( workspaces: workspaces, title: title, message: message, details: titleLines, willCloseWindow: willCloseWindow ) } private func closeWorkspaceDisplayTitle(_ title: String?) -> String { let collapsed = title? .replacingOccurrences(of: "\n", with: " ") .replacingOccurrences(of: "\r", with: " ") .trimmingCharacters(in: .whitespacesAndNewlines) if let collapsed, !collapsed.isEmpty { return collapsed } return String(localized: "workspace.displayName.fallback", defaultValue: "Workspace") } private func closeWorkspaceIfRunningProcess( _ workspace: Workspace, requiresConfirmation: Bool = true, source: CloseConfirmationSource = .workspace, closeAlreadyConfirmed: Bool = false ) -> Bool { guard !workspace.isChiefHome else { return false } // Closing a group's anchor is non-destructive to the group: its next // member is promoted to anchor in closeWorkspace, so the members stay // grouped instead of scattering to root. No special anchor prompt is // needed; the normal running-process confirmation below still applies. let willCloseWindow = tabs.count <= 1 let windowDockNeedsConfirmation = willCloseWindow && AppDelegate.shared?.existingWindowDock(for: self)?.needsConfirmClose() == true let needsCloseConfirmation = workspaceNeedsConfirmClose(workspace) || windowDockNeedsConfirmation let alwaysConfirm = CloseTabWarningStore(defaults: closeTabWarningDefaults).alwaysConfirmsWorkspaceClose let warnsWithoutAlwaysConfirm = needsCloseConfirmation || shouldConfirmWorkspaceClose( requiresConfirmation: needsCloseConfirmation, source: source ) let showsCloseConfirmation = requiresConfirmation && (warnsWithoutAlwaysConfirm || alwaysConfirm) let dontAskAgain = Self.workspaceCloseDontAskAgain( ordinary: needsCloseConfirmation ? [.workspace, .safety] : .workspace, ordinaryWarns: warnsWithoutAlwaysConfirm, alwaysConfirm: alwaysConfirm ) if showsCloseConfirmation, !confirmClose( title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."), acceptCmdD: willCloseWindow, dontAskAgain: dontAskAgain ) { return false } // Reaching here after a shown dialog means the user accepted it. let closeConfirmed = closeAlreadyConfirmed || showsCloseConfirmation if tabs.count <= 1 { // Last workspace in this window closes via the window-close path. For a // remote-tmux mirror this DETACHES from the remote session (kept alive for // resume); the mark seam is a retained no-op (see // markRemoteTmuxKillOnWindowCloseIfNeeded). Non-last workspaces also detach // via closeWorkspace. markRemoteTmuxKillOnWindowCloseIfNeeded(for: [workspace]) closeWindowForLastWorkspace( workspaceId: workspace.id, closeAlreadyConfirmed: closeConfirmed ) } else { closeWorkspace(workspace) } return true } private func shouldConfirmClose(requiresConfirmation: Bool, source: CloseConfirmationSource) -> Bool { switch source { case .workspace: return requiresConfirmation case .tabClose: return CloseTabWarningStore(defaults: closeTabWarningDefaults).shouldConfirmCloseIncludingSafety( requiresConfirmation: requiresConfirmation, source: .shortcut ) case .tabCloseButton: return CloseTabWarningStore(defaults: closeTabWarningDefaults).shouldConfirmCloseIncludingSafety( requiresConfirmation: requiresConfirmation, source: .tabCloseButton ) } } /// Gate for the "Close workspace?" and "Close workspaces?" prompts: the /// source's own gate plus `app.warnBeforeClosingWorkspace`. The pinned /// prompt uses `shouldConfirmClose` directly, so turning this setting off /// never removes the protection a user asked for by pinning. private func shouldConfirmWorkspaceClose(requiresConfirmation: Bool, source: CloseConfirmationSource) -> Bool { AppCatalogSection().warnBeforeClosingWorkspace.value(in: closeTabWarningDefaults) && shouldConfirmConfiguredClose(requiresConfirmation: requiresConfirmation, source: source) } /// What "Don't ask again" turns off on a workspace close prompt. When the /// prompt shows only because of `app.alwaysConfirmWorkspaceClose`, it turns /// off just that setting and leaves the ordinary workspace and window /// warnings alone. When the ordinary warning would have asked too, it turns /// off both, so the same close does not ask next time. static func workspaceCloseDontAskAgain( ordinary: CloseWarningKinds, ordinaryWarns: Bool, alwaysConfirm: Bool ) -> CloseWarningKinds { guard alwaysConfirm else { return ordinary } guard ordinaryWarns else { return .alwaysConfirmWorkspace } return ordinary.union(.alwaysConfirmWorkspace) } /// Applies the user-configured warning toggles without turning the /// `requiresConfirmation` argument into a safety warning. Workspace and /// batch gates use this seam before they have established whether any /// panel contains a live process; the actual close path uses /// `shouldConfirmClose`, which also includes the non-suppressible safety /// gate. private func shouldConfirmConfiguredClose(requiresConfirmation: Bool, source: CloseConfirmationSource) -> Bool { switch source { case .workspace: return requiresConfirmation case .tabClose: return CloseTabWarningStore(defaults: closeTabWarningDefaults).shouldConfirmClose( requiresConfirmation: requiresConfirmation, source: .shortcut ) case .tabCloseButton: return CloseTabWarningStore(defaults: closeTabWarningDefaults).shouldConfirmClose( requiresConfirmation: requiresConfirmation, source: .tabCloseButton ) } } /// Whether the Close Window command should ask before closing this /// manager's window: `app.warnBeforeClosingWindow` is on and a panel that /// closes with the window needs close confirmation, either in a workspace /// or in the window Dock (which the caller owns and checks). func shouldConfirmWindowClose(windowDockNeedsConfirmation: Bool) -> Bool { let anyPanelNeedsConfirmation = windowDockNeedsConfirmation || tabs.contains(where: workspaceNeedsConfirmClose) if anyPanelNeedsConfirmation { // A live foreground process must always get a chance to survive a // window close, even when the ordinary window warning is disabled. return true } return CloseTabWarningStore(defaults: closeTabWarningDefaults) .shouldConfirmWindowClose(anyPanelNeedsConfirmation: false) } private enum PinnedWorkspaceCloseConfirmation { case notNeeded case confirmed case cancelled } private func confirmPinnedWorkspaceClose(source: CloseConfirmationSource) -> PinnedWorkspaceCloseConfirmation { guard shouldConfirmClose(requiresConfirmation: true, source: source) else { return .notNeeded } let accepted = confirmClose( title: String(localized: "dialog.closePinnedWorkspace.title", defaultValue: "Close pinned workspace?"), message: String( localized: "dialog.closePinnedWorkspace.message", defaultValue: "This workspace is pinned. Closing it will close the workspace and all of its panels." ), acceptCmdD: tabs.count <= 1 ) return accepted ? .confirmed : .cancelled } /// Closes this manager's window after its last workspace goes. When the user /// already accepted a close dialog, the window close carries that answer so /// the last-window path does not ask again. Returns false when there is no /// window route to close. @discardableResult private func closeWindowForLastWorkspace(workspaceId: UUID, closeAlreadyConfirmed: Bool) -> Bool { if let window { if closeAlreadyConfirmed, let app = AppDelegate.shared { app.performPreconfirmedMainWindowClose(window) } else { window.performClose(nil) } return true } guard let app = AppDelegate.shared else { return false } app.closeMainWindowContainingTabId(workspaceId, closeAlreadyConfirmed: closeAlreadyConfirmed) return true } private func shouldCloseWorkspaceOnLastSurfaceShortcut(_ workspace: Workspace, panelId: UUID) -> Bool { // Stored under the legacy closeWorkspaceOnLastSurfaceShortcut key: // true means the Close shortcut closes the workspace on its last surface. closeWorkspaceOnLastSurfacePreferenceEnabled() && workspace.panels.count <= 1 && workspace.panels[panelId] != nil } func closeWorkspaceOnLastSurfacePreferenceEnabled() -> Bool { settings.value(for: settingsCatalog.app.keepWorkspaceOpenWhenClosingLastSurface) } private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) { // Mirror leaves are not in workspace.panels. Resolve the focused leaf // before the outer container can fall through to a whole-window close. let remotePane = tab.remoteTmuxControlPane(surfaceID: panelId) ?? tab.activeRemoteTmuxControlPane(containerPanelID: panelId) if let remotePane { let prompt = DockPaneCloseConfirmationPrompt(titles: [remotePane.pane.panel.displayTitle]) guard confirmClose( title: prompt.title, message: prompt.message, acceptCmdD: false ) else { return } // Modal confirmation can outlive a layout change. Re-resolve the // exact surface, never a newly focused pane or the window wrapper. _ = tab.remoteTmuxControlPane(surfaceID: remotePane.pane.panel.id)?.requestKill() return } guard !tab.isRemoteTmuxControlContainer(panelId) else { return } guard tab.panels[panelId] != nil else { #if DEBUG cmuxDebugLog( "surface.close.shortcut.skip tab=\(tab.id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) reason=missingPanel" ) #endif return } let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in partial + tab.bonsplitController.tabs(inPane: paneId).count } let panelKind: String = { guard let panel = tab.panels[panelId] else { return "missing" } if panel is TerminalPanel { return "terminal" } if panel is BrowserPanel { return "browser" } return String(describing: type(of: panel)) }() let closesWorkspaceOnLastSurfaceShortcut = shouldCloseWorkspaceOnLastSurfaceShortcut(tab, panelId: panelId) #if DEBUG cmuxDebugLog( "surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " + "panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount) " + "closeWorkspaceOnLastSurface=\(closesWorkspaceOnLastSurfaceShortcut ? 1 : 0)" ) #endif if closesWorkspaceOnLastSurfaceShortcut, let surfaceId = tab.surfaceIdFromPanelId(panelId) { tab.markExplicitClose(surfaceId: surfaceId) } tab.markCloseHistoryEligible(panelId: panelId) let closed = tab.closePanel(panelId) #if DEBUG cmuxDebugLog( "surface.close.shortcut tab=\(tab.id.uuidString.prefix(5)) " + "panel=\(panelId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) " + "panelsAfterCall=\(tab.panels.count)" ) #endif } private func shortcutCloseTargetPanelId(in workspace: Workspace) -> UUID? { if let focusedPanelId = workspace.focusedPanelId, workspace.remoteTmuxControlPane(surfaceID: focusedPanelId) != nil { return focusedPanelId } if let focusedPanelId = workspace.focusedPanelId, workspace.panels[focusedPanelId] != nil { return focusedPanelId } if workspace.panels.count == 1 { return workspace.panels.keys.first } let candidatePane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first if let candidatePane, let selectedTabId = workspace.bonsplitController.selectedTab(inPane: candidatePane)?.id ?? workspace.bonsplitController.tabs(inPane: candidatePane).first?.id, let panelId = workspace.panelIdFromSurfaceId(selectedTabId), workspace.panels[panelId] != nil { return panelId } return nil } func closePanelWithConfirmation(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } closePanelWithConfirmation(tab: tab, panelId: surfaceId) } /// Runtime close requests from Ghostty should only ever target the specific surface. /// They must not escalate into workspace/window-close semantics for "last tab". func closeRuntimeSurfaceWithConfirmation(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } if tab.panels[surfaceId] == nil { tab.closeDockPanelAndClearNotifications( surfaceId, force: false, recordsHistory: false ) return } let requiresConfirmation: Bool if let terminalPanel = tab.terminalPanel(for: surfaceId), tab.panelNeedsConfirmClose(panelId: surfaceId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) { requiresConfirmation = true } else { requiresConfirmation = false } let warningKinds = CloseTabWarningStore(defaults: closeTabWarningDefaults).warningKindsIncludingSafety( requiresConfirmation: requiresConfirmation, source: .shortcut ) if !warningKinds.isEmpty { guard confirmClose( title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), acceptCmdD: false, dontAskAgain: warningKinds ) else { return } } _ = tab.closePanel(surfaceId, force: true) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId) } /// Runtime close requests from Ghostty without confirmation (e.g. child-exit). /// This path must only close the addressed surface and must never close the workspace window. func closeRuntimeSurface(tabId: UUID, surfaceId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } if tab.panels[surfaceId] == nil { tab.closeDockPanelAndClearNotifications( surfaceId, force: true, recordsHistory: false ) return } #if DEBUG cmuxDebugLog( "surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " + "surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)" ) #endif // Keep AppKit first responder in sync with workspace focus before routing the close. // If split reparenting caused a temporary model/view mismatch, fallback close logic in // Workspace.closePanel uses focused selection to resolve the correct tab deterministically. reconcileFocusedPanelFromFirstResponderForKeyboard() let closed = tab.closePanel(surfaceId, force: true) #if DEBUG cmuxDebugLog( "surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " + "surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)" ) #endif AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId) } /// Handles Ghostty's `SHOW_CHILD_EXITED` action without prompting because the /// process is already gone; startup failures may keep the addressed surface visible. func closePanelAfterChildExited( tabId: UUID, surfaceId: UUID, runtimeSurface: TerminalSurface? = nil, keepSurfaceVisible: Bool = false ) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } if tab.panels[surfaceId] == nil { tab.closeDockPanelAndClearNotifications( surfaceId, force: true, recordsHistory: false ) return } if let runtimeSurface, tab.terminalPanel(for: surfaceId)?.surface !== runtimeSurface { return } let ownsRemoteChildExit = tab.isRemoteTerminalSurface(surfaceId) || tab.pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) || tab.remoteDisconnectPlaceholderPanelIds.contains(surfaceId) let keepsPersistentRemoteSurfaceOpen = tab.shouldKeepPersistentRemoteSurfaceOpenAfterChildExit(surfaceId) if !keepsPersistentRemoteSurfaceOpen, tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId) { let relayPort: Int? = tab.remoteConfiguration?.transport == .ssh ? tab.remoteConfiguration?.relayPort : nil tab.markRemoteTerminalSessionEnded( surfaceId: surfaceId, relayPort: relayPort, allowUntracked: ownsRemoteChildExit ) } let handlesRemoteExitThroughWorkspace = ownsRemoteChildExit && tab.pendingRemoteTerminalChildExitSurfaceIds.contains(surfaceId) #if DEBUG cmuxDebugLog( "surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " + "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count) " + "remoteWorkspace=\(tab.isRemoteWorkspace ? 1 : 0) keepRemote=\(handlesRemoteExitThroughWorkspace ? 1 : 0) " + "keepPersistentRemote=\(keepsPersistentRemoteSurfaceOpen ? 1 : 0)" ) #endif // A persistent SSH workspace must keep the exited surface visible so the user can // inspect the failure and retry instead of silently falling back to a local shell. if keepsPersistentRemoteSurfaceOpen { tab.markPersistentRemotePTYAttachFailed(surfaceId: surfaceId) return } if keepSurfaceVisible { return } // Workspace owns remote active -> disconnected transitions and preserves pane history. if handlesRemoteExitThroughWorkspace { guard !tab.transitionRemoteTerminalToDisconnectedPlaceholder(surfaceId: surfaceId) else { return } closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) 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 { // Notification cleanup belongs to the committed outcome: // unregisterMainWindow handles a closed window and // applicationWillTerminate clears the store on Quit. let exitedSurface = runtimeSurface ?? tab.terminalPanel(for: surfaceId)?.surface let onCancelled = exitedSurface.flatMap { lastTerminalChildExitRecoveryAction( tabId: tabId, surfaceId: surfaceId, runtimeSurface: $0 ) } app.closeMainWindowContainingTabId( tabId, recordHistory: false, onCancelled: onCancelled ) } else { // Headless/test fallback when no AppDelegate window context exists. closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) } } else { closeWorkspace(tab, recordHistory: false) } return } closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) } /// Returns a one-shot close-transaction rollback. It revalidates the exact /// terminal identity before replacing the exited renderer with a fresh shell. func lastTerminalChildExitRecoveryAction( tabId: UUID, surfaceId: UUID, runtimeSurface: TerminalSurface ) -> (() -> Void)? { guard tabs.count == 1, let workspace = tabs.first, workspace.id == tabId, workspace.panels.count == 1, workspace.terminalPanel(for: surfaceId)?.surface === runtimeSurface else { return nil } return { [weak self, weak workspace, weak runtimeSurface] in guard let self, let workspace, let runtimeSurface, self.tabs.count == 1, self.tabs.first === workspace, workspace.panels.count == 1, workspace.terminalPanel(for: surfaceId)?.surface === runtimeSurface else { return } _ = workspace.respawnTerminalSurface( panelId: surfaceId, command: nil, focus: true, replayScrollback: nil, replayFileURL: nil ) } } func workspaceNeedsConfirmClose(_ workspace: Workspace) -> Bool { #if DEBUG if ProcessInfo.processInfo.environment["CMUX_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] == "1" { return true } #endif return workspace.needsConfirmClose() } func workspaceNeedsConfirmCloseForClose(_ workspace: Workspace) -> Bool { workspaceNeedsConfirmClose(workspace) } func titleForTab(_ tabId: UUID) -> String? { workspacesById[tabId]?.title } // MARK: - Panel/Surface ID Access /// Returns the focused panel ID for a tab (replaces focusedSurfaceId) func focusedPanelId(for tabId: UUID) -> UUID? { tabs.first(where: { $0.id == tabId })?.focusedPanelId } @discardableResult func performFocusedBrowserZoom(_ action: (BrowserPanel) -> Bool) -> Bool? { guard let panel = focusedBrowserPanel else { return nil } return action(panel) } func performFocusedTextFilePreviewZoom(_ action: (FilePreviewPanel) -> Bool) -> Bool? { guard let panel = focusedTextFilePreviewPanel else { return nil } return action(panel) } /// Runs a Ghostty font-size binding on the focused terminal, or returns nil /// when no terminal is focused. func performFocusedTerminalFontSizeAction(_ action: String) -> Bool? { guard let panel = selectedTerminalPanel else { return nil } return panel.performBindingAction(action) } func zoomInFocusedBrowser() -> Bool { performFocusedBrowserZoom { $0.zoomIn() } ?? false } @discardableResult func zoomOutFocusedBrowser() -> Bool { performFocusedBrowserZoom { $0.zoomOut() } ?? false } @discardableResult func resetZoomFocusedBrowser() -> Bool { performFocusedBrowserZoom { $0.resetZoom() } ?? false } var canToggleBrowserFocusModeForFocusedBrowser: Bool { focusedBrowserPanel?.canToggleBrowserFocusMode == true } @discardableResult func toggleBrowserFocusModeForFocusedBrowser(reason: String) -> Bool { guard let browserPanel = focusedBrowserPanel else { return false } return browserPanel.toggleBrowserFocusMode(reason: reason, focusWebView: true) } @discardableResult func setFocusedBrowserFocusModeActive(_ active: Bool, reason: String) -> Bool { guard let browserPanel = focusedBrowserPanel else { return false } return browserPanel.setBrowserFocusModeActive(active, reason: reason, focusWebView: active) } @discardableResult func zoomInFocusedMarkdown() -> Bool { focusedMarkdownPanel?.zoomIn() ?? false } @discardableResult func zoomOutFocusedMarkdown() -> Bool { focusedMarkdownPanel?.zoomOut() ?? false } @discardableResult func resetZoomFocusedMarkdown() -> Bool { focusedMarkdownPanel?.resetZoom() ?? false } @discardableResult func toggleDeveloperToolsFocusedBrowser() -> Bool { focusedBrowserPanel?.toggleDeveloperTools() ?? false } @discardableResult func showJavaScriptConsoleFocusedBrowser() -> Bool { focusedBrowserPanel?.showDeveloperToolsConsole() ?? false } @discardableResult func toggleOmnibarFocusedBrowser() -> Bool { guard let panel = focusedBrowserPanel else { return false } panel.toggleOmnibarVisibility() return true } @discardableResult func toggleReactGrabFromCurrentFocus() -> Bool { guard let workspace = selectedWorkspace else { return false } return toggleReactGrab(in: workspace, browserSurfaceId: nil, returnTerminalSurfaceId: nil) != nil } /// The browser ``toggleReactGrab(in:browserSurfaceId:returnTerminalSurfaceId:)`` /// acts on, without acting: an explicit surface is authoritative (it /// must be a browser, no fallback to a different browser); otherwise the /// route's browser from focus. The socket method asks it first, to /// refuse a tab a browser REPL session drives. func reactGrabBrowserPanelId(in workspace: Workspace, browserSurfaceId: UUID?) -> UUID? { reactGrabBrowserPanelId(in: workspace, browserSurfaceId: browserSurfaceId, route: reactGrabShortcutRoute(in: workspace)) } private func reactGrabShortcutRoute(in workspace: Workspace) -> ReactGrabShortcutRoute? { resolveReactGrabShortcutRoute(panels: workspace.panels.values.map { panel in ReactGrabShortcutPanelSnapshot( id: panel.id, panelType: panel.panelType, isFocused: panel.id == workspace.focusedPanelId ) }) } private func reactGrabBrowserPanelId(in workspace: Workspace, browserSurfaceId: UUID?, route: ReactGrabShortcutRoute?) -> UUID? { if let explicit = browserSurfaceId { return workspace.browserPanel(for: explicit) != nil ? explicit : nil } return route?.browserPanelId } /// Toggles React Grab for a specific workspace. When `browserSurfaceId`/`returnTerminalSurfaceId` /// are nil this mirrors the keyboard shortcut: it resolves the browser + return terminal from the /// focused panel layout. An explicit browser surface (must be a browser) or return terminal /// (must be a terminal) overrides that route. Used by both the Cmd+Shift+G shortcut and the /// `cmux browser react-grab toggle` CLI command so both share one action path. /// Returns the resolved browser surface id it acted on, or nil if it could not resolve/act /// (so callers can report the actual browser surface rather than the focused panel). @discardableResult func toggleReactGrab( in workspace: Workspace, browserSurfaceId: UUID?, returnTerminalSurfaceId: UUID? ) -> UUID? { let route = reactGrabShortcutRoute(in: workspace) guard let browserPanelId = reactGrabBrowserPanelId(in: workspace, browserSurfaceId: browserSurfaceId, route: route) else { return nil } // Return terminal: an explicit return surface is authoritative (must be a terminal in // this workspace, no fallback) so pasteback never silently goes to the wrong terminal. // With no explicit return, adopt the route's terminal only when the browser also came // from the route (matching shortcut semantics). let returnTerminalPanelId: UUID? if let explicit = returnTerminalSurfaceId { guard workspace.panels[explicit]?.panelType == .terminal else { return nil } returnTerminalPanelId = explicit } else if browserSurfaceId == nil { returnTerminalPanelId = route?.returnTerminalPanelId } else { returnTerminalPanelId = nil } let didToggle = performReactGrabToggle( in: workspace, browserPanelId: browserPanelId, returnTerminalPanelId: returnTerminalPanelId ) return didToggle ? browserPanelId : nil } @discardableResult private func performReactGrabToggle( in workspace: Workspace, browserPanelId: UUID, returnTerminalPanelId: UUID? ) -> Bool { guard let browserPanel = workspace.browserPanel(for: browserPanelId) else { return false } if let returnTerminalPanelId { browserPanel.armReactGrabRoundTrip(returnTo: returnTerminalPanelId) } else { browserPanel.clearReactGrabRoundTrip(reason: "shortcut.noReturnTarget") } if workspace.focusedPanelId != browserPanel.id { workspace.clearSplitZoom() workspace.focusPanel(browserPanel.id) } let didRequestExplicitWebViewFocus = browserPanel.requestExplicitWebViewFocus() #if DEBUG cmuxDebugLog( "reactGrab.pasteback h1.focusRequestResult " + "workspace=\(workspace.id.uuidString.prefix(5)) " + "browser=\(browserPanel.id.uuidString.prefix(5)) " + "return=\(returnTerminalPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil") " + "success=\(didRequestExplicitWebViewFocus ? 1 : 0)" ) #endif Task { @MainActor [weak browserPanel] in guard let browserPanel else { return } if returnTerminalPanelId != nil { await browserPanel.ensureReactGrabActive() } else { await browserPanel.toggleOrInjectReactGrab() } if !didRequestExplicitWebViewFocus { _ = browserPanel.requestExplicitWebViewFocus() } } return true } /// Returns the exact focused surface, projecting a remote-tmux container /// through its authoritative active pane. func focusedSurfaceId(for tabId: UUID) -> UUID? { guard let workspace = workspacesById[tabId], let focusedPanelID = workspace.focusedPanelId else { return nil } return workspace.surfaceOwnershipTarget(for: focusedPanelID)?.surfaceID } func rememberFocusedSurface(tabId: UUID, surfaceId: UUID) { lastFocusedPanelByTab[tabId] = surfaceId } func applyWindowBackgroundForSelectedTab() { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let terminalPanel = tab.focusedTerminalInputTarget()?.panel else { return } terminalPanel.applyWindowBackgroundIfActive() } func applyWindowBackdropModeForAllTabs(reason: String) { let config = WorkspaceContentView.resolveGhosttyAppearanceConfig( reason: reason ) for tab in tabs { tab.applyGhosttyChrome( from: config, reason: reason ) } applyWindowBackgroundForSelectedTab() } private func focusSelectedTabPanel() { if let request = workspaceHandoffRetirementGate.pendingRequest, request.selectionGeneration == selectionSideEffectsGeneration { prepareWorkspaceUnfocusTarget(for: request.workspaceID) } guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }) else { return } let panelId: UUID if let restoredPanelId = lastFocusedPanelByTab[selectedTabId], tab.panels[restoredPanelId] != nil { panelId = restoredPanelId } else if let focusedPanelId = tab.focusedPanelId, tab.panels[focusedPanelId] != nil { panelId = focusedPanelId } else { return } // Route workspace reactivation through the normal focus machinery so panel-local // activation intents like browser find-field focus are restored on return. tab.focusPanel(panelId) } func completePendingWorkspaceUnfocus(reason: String) { guard let pending = pendingWorkspaceUnfocusTarget else { return } // If this tab became selected again before handoff completion, drop the stale // pending entry so it cannot be flushed later and deactivate the selected workspace. guard Self.shouldUnfocusPendingWorkspace( pendingTabId: pending.tabId, selectedTabId: selectedTabId ) else { pendingWorkspaceUnfocusTarget = nil #if DEBUG cmuxDebugLog( "ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=selected_again" ) #endif return } pendingWorkspaceUnfocusTarget = nil unfocusWorkspacePanel(tabId: pending.tabId, panelId: pending.panelId) #if DEBUG if let snapshot = debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 cmuxDebugLog( "ws.unfocus.complete id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " + "tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)" ) } else { cmuxDebugLog( "ws.unfocus.complete id=none tab=\(Self.debugShortWorkspaceId(pending.tabId)) " + "panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)" ) } #endif } /// Records a mount-authoritative handoff retirement until the matching /// deferred focus pass has established the source panel's unfocus target. func requestWorkspaceHandoffRetirement(workspaceID: UUID, reason: String) { guard selectedTabId != workspaceID else { workspaceHandoffRetirementGate.cancel() return } guard workspaceHandoffRetirementGate.isTracking( selectionGeneration: selectionSideEffectsGeneration ) else { return } if let retirement = workspaceHandoffRetirementGate.request( workspaceID: workspaceID, reason: reason, selectionGeneration: selectionSideEffectsGeneration ) { prepareWorkspaceUnfocusTarget(for: workspaceID) completeWorkspaceHandoffRetirement(retirement) } else { prepareWorkspaceUnfocusTarget(for: workspaceID) } } func cancelPendingWorkspaceHandoffRetirement() { workspaceHandoffRetirementGate.cancel() } private func completeWorkspaceHandoffRetirement( _ retirement: WorkspaceHandoffRetirementGate.Request ) { completePendingWorkspaceUnfocus(reason: retirement.reason) workspaceSwitchCoordinator.sourceDidRetire( workspaceID: retirement.workspaceID, targetWorkspaceID: selectedTabId ) } private func prepareWorkspaceUnfocusTarget(for workspaceID: UUID) { guard let workspace = tabs.first(where: { $0.id == workspaceID }), let panelID = workspace.focusedPanelId, workspace.panels[panelID] != nil else { return } replacePendingWorkspaceUnfocusTarget( with: (tabId: workspaceID, panelId: panelID) ) } private func replacePendingWorkspaceUnfocusTarget(with next: (tabId: UUID, panelId: UUID)) { if let current = pendingWorkspaceUnfocusTarget, current.tabId == next.tabId, current.panelId == next.panelId { return } if let current = pendingWorkspaceUnfocusTarget { // Never unfocus the currently selected workspace when replacing stale pending state. if Self.shouldUnfocusPendingWorkspace( pendingTabId: current.tabId, selectedTabId: selectedTabId ) { unfocusWorkspacePanel(tabId: current.tabId, panelId: current.panelId) #if DEBUG cmuxDebugLog( "ws.unfocus.flush tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced" ) #endif } else { #if DEBUG cmuxDebugLog( "ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced_selected" ) #endif } } pendingWorkspaceUnfocusTarget = next #if DEBUG if let snapshot = debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 cmuxDebugLog( "ws.unfocus.defer id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " + "tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))" ) } else { cmuxDebugLog( "ws.unfocus.defer id=none tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))" ) } #endif } private func unfocusWorkspacePanel(tabId: UUID, panelId: UUID) { guard let tab = tabs.first(where: { $0.id == tabId }), let panel = tab.panels[panelId] else { return } panel.unfocus() } static func shouldUnfocusPendingWorkspace(pendingTabId: UUID, selectedTabId: UUID?) -> Bool { selectedTabId != pendingTabId } // MARK: Notification dismissal (CmuxNotifications) // // The dismissal decision flow lives in NotificationDismissalModel; // TabManager hosts its seam (TabManager+NotificationDismissalHosting) // and forwards the legacy entry points below. private func selectWorkspaceId( _ tabId: UUID, notificationDismissalContext: NotificationDismissalContext? ) { guard selectedTabId != tabId else { // Selecting the workspace that is already selected (its sidebar row, ⌘1…9, the // palette, `cmux select-workspace`) still means "show this workspace": leave Chief // Home, which a selection change does through ContentView's selectedTabId observer. AppDelegate.shared?.leaveChiefHome(in: self) notificationDismissal.setPendingSelectionContext(nil) if let notificationDismissalContext { notificationDismissal.dismissFocusedPanelNotificationIfActive( workspaceId: tabId, context: notificationDismissalContext ) } return } notificationDismissal.setPendingSelectionContext(notificationDismissalContext) selectedTabId = tabId } private func dismissFocusedPanelNotificationIfActive( tabId: UUID, context: NotificationDismissalContext = .activeFocus ) { notificationDismissal.dismissFocusedPanelNotificationIfActive(workspaceId: tabId, context: context) } private func dismissPanelNotificationOnFocus(tabId: UUID, panelId: UUID, explicitFocusIntent: Bool) { notificationDismissal.dismissPanelNotificationOnFocus( workspaceId: tabId, panelId: panelId, explicitFocusIntent: explicitFocusIntent ) } @discardableResult func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool { notificationDismissal.dismissNotificationOnDirectInteraction(workspaceId: tabId, surfaceId: surfaceId) } /// A rendered pane is a visible read, even when it is a non-focused split /// surface. Keep the selection guard and active-app policy in the shared /// dismissal model while targeting that concrete panel. func dismissNotificationOnVisiblePanel(tabId: UUID, panelId: UUID) { notificationDismissal.dismissPanelNotificationOnFocus( workspaceId: tabId, panelId: panelId, explicitFocusIntent: false ) } @discardableResult func dismissNotificationOnTerminalInteraction(tabId: UUID, surfaceId: UUID?) -> Bool { notificationDismissal.dismissNotificationOnTerminalInteraction(workspaceId: tabId, surfaceId: surfaceId) } private func enqueuePanelTitleUpdate(_ change: GhosttyTitleChange, sourceSurface: TerminalSurface) { let trimmed = AutomaticTerminalTitle(change.title)?.value.trimmingCharacters(in: .whitespacesAndNewlines) guard let trimmed, !trimmed.isEmpty else { return } guard workspacesById[change.tabId]?.terminalPanel(for: change.surfaceId)?.surface === sourceSurface else { return } #if DEBUG if PanelTitleUpdateCoalescingSettings.diagnosticsEnabled(settings: settings) { cmuxDebugLog( "workspace.title.enqueue workspace=\(Self.debugShortWorkspaceId(change.tabId)) " + "panel=\(change.surfaceId.uuidString.prefix(5)) title=\"\(Self.debugTitlePreview(trimmed))\"" ) } #endif let trimmedStable = change.stableTitle.trimmingCharacters(in: .whitespacesAndNewlines) let key = PanelTitleUpdateKey(tabId: change.tabId, panelId: change.surfaceId) pendingPanelTitleUpdates[key] = PendingPanelTitleUpdate( title: trimmed, stableTitle: trimmedStable.isEmpty ? trimmed : trimmedStable, sourceSurface: sourceSurface, sourceTerminalLifecycleId: sourceSurface.terminalLifecycleId ) panelTitleUpdateCoalescer.signal( delay: PanelTitleUpdateCoalescingSettings.delay(settings: settings) ) { [weak self] in self?.flushPendingPanelTitleUpdates() } } private func flushPendingPanelTitleUpdates() { guard !pendingPanelTitleUpdates.isEmpty else { return } let updates = pendingPanelTitleUpdates pendingPanelTitleUpdates.removeAll(keepingCapacity: true) #if DEBUG if PanelTitleUpdateCoalescingSettings.diagnosticsEnabled(settings: settings) { cmuxDebugLog("workspace.title.flush pending=\(updates.count)") } #endif for (key, update) in updates { guard let sourceSurface = update.sourceSurface, sourceSurface.terminalLifecycleId == update.sourceTerminalLifecycleId else { continue } updatePanelTitle( tabId: key.tabId, panelId: key.panelId, title: update.title, stableTitle: update.stableTitle, sourceSurface: sourceSurface ) } } func flushPendingPanelTitleUpdatesForWorkspaceSnapshot() { panelTitleUpdateCoalescer.flushNow() } @discardableResult func updatePanelTitle(tabId: UUID, panelId: UUID, title: String) -> Bool { applyPanelTitle(tabId: tabId, panelId: panelId, title: title, stableTitle: nil, sourceSurface: nil) } @discardableResult private func applyPanelTitle( tabId: UUID, panelId: UUID, title: String, stableTitle: String?, sourceSurface: TerminalSurface? ) -> Bool { guard let tab = workspacesById[tabId] else { return false } if let sourceSurface { // Batched flush path: the queued surface must still own the panel. guard let terminalPanel = tab.terminalPanel(for: panelId), terminalPanel.surface === sourceSurface else { return false } } let previousDisplayTitle = resolvedWorkspaceDisplayTitle(for: tab).trimmingCharacters(in: .whitespacesAndNewlines) let applied = tab.updatePanelTitle(panelId: panelId, title: title, stableTitle: stableTitle) guard !tab.isRemoteTmuxMirror else { return applied } // A spinner-only frame has already refreshed the AppKit tab label inside // `tab.updatePanelTitle`. Nothing below it can produce a different result, // so stop before the window title and the display-title comparison. guard applied else { return false } if tab.focusedPanelId == panelId, selectedTabId == tabId { updateWindowTitle(for: tab) } let currentDisplayTitle = resolvedWorkspaceDisplayTitle(for: tab).trimmingCharacters(in: .whitespacesAndNewlines) if currentDisplayTitle != previousDisplayTitle { NotificationCenter.default.post( name: .workspaceTitleDidChange, object: self, userInfo: [ GhosttyNotificationKey.tabId: tabId, GhosttyNotificationKey.surfaceId: panelId, ] ) } return applied } private func updatePanelTitle( tabId: UUID, panelId: UUID, title: String, stableTitle: String, sourceSurface: TerminalSurface ) { applyPanelTitle(tabId: tabId, panelId: panelId, title: title, stableTitle: stableTitle, sourceSurface: sourceSurface) } func shouldScheduleRawTitleRefresh(forWorkspaceId workspaceId: UUID?) -> Bool { workspaceId == selectedTabId && !PanelTitleUpdateCoalescingSettings.isEnabled(settings: settings) } func focusedSurfaceTitleDidChange(tabId: UUID) { guard let tab = workspacesById[tabId], !tab.isRemoteTmuxMirror, let focusedPanelId = tab.focusedPanelId else { return } tab.applyFocusedPanelTitle(panelId: focusedPanelId) if selectedTabId == tabId { updateWindowTitle(for: tab) } } func focusTab( _ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false, focusIntent: PanelFocusIntent? = nil, dismissRestoredUnreadOnResume: Bool? = nil, focusTransactionId: UUID? = nil, focusPanelIdOverride: UUID? = nil ) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } let targetPanelId = surfaceId.flatMap { panelId(forSurfaceOrPanelId: $0, in: tab) } if let targetPanelId { // Keep selected-surface intent stable across selectedTabId didSet async restore. lastFocusedPanelByTab[tabId] = targetPanelId } let shouldDismissRestoredUnread = dismissRestoredUnreadOnResume ?? !suppressFlash let dismissalContext: NotificationDismissalContext? = shouldDismissRestoredUnread ? .explicitWorkspaceResume : nil let shouldDeferSelectedWorkspaceDismissal = selectedTabId == tabId && targetPanelId.map { $0 != focusedPanelId(for: tabId) } == true #if DEBUG debugPrimeWorkspaceSwitchTrigger("focus", to: tabId) #endif selectWorkspaceId( tabId, notificationDismissalContext: shouldDeferSelectedWorkspaceDismissal ? nil : dismissalContext ) NotificationCenter.default.post( name: .ghosttyDidFocusTab, object: nil, userInfo: [GhosttyNotificationKey.tabId: tabId] ) if let surfaceId { let focusPanelId = focusPanelIdOverride ?? tab.surfaceOwnershipTarget(for: surfaceId)?.surfaceID ?? targetPanelId ?? surfaceId if !suppressFlash { focusSurface(tabId: tabId, surfaceId: focusPanelId, focusTransactionId: focusTransactionId) } else { tab.focusPanel(focusPanelId, focusIntent: focusIntent, focusTransactionId: focusTransactionId) } if let dismissalContext { _ = notificationDismissal.dismissNotification( workspaceId: tabId, surfaceId: surfaceId, context: dismissalContext ) } } } @discardableResult func focusTabFromNotification( _ tabId: UUID, surfaceId: UUID? = nil, completion: ((Bool) -> Void)? = nil, effectIsCurrent: @escaping @MainActor @Sendable () -> Bool = { true } ) -> Bool { guard effectIsCurrent() else { completion?(false) return false } guard let tab = tabs.first(where: { $0.id == tabId }) else { #if DEBUG cmuxDebugLog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) reason=missingTab") #endif completion?(false) return false } let requestedPanelId = surfaceId.flatMap { panelId(forSurfaceOrPanelId: $0, in: tab) } if let surfaceId, requestedPanelId == nil { #if DEBUG cmuxDebugLog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) reason=missingPanel") #endif completion?(false) return false } let desiredPanelId = requestedPanelId ?? tab.focusedPanelId #if DEBUG if let desiredPanelId { AppDelegate.shared?.armJumpUnreadFocusRecord(tabId: tabId, surfaceId: desiredPanelId) } #endif if let surfaceId, let location = tab.remoteTmuxControlPane(surfaceID: surfaceId), location.containerPanelID != surfaceId { let requestID = UUID() pendingProjectedNotificationFocusRequestID = requestID let accepted = location.controlFocus { [weak self] confirmed in guard let self else { return } guard self.pendingProjectedNotificationFocusRequestID == requestID else { return } guard effectIsCurrent() else { self.pendingProjectedNotificationFocusRequestID = nil completion?(false) return } self.pendingProjectedNotificationFocusRequestID = nil guard confirmed else { completion?(false) return } // Present and dismiss only after tmux publishes the requested // pane as authoritative. Until then, the mirror's optimistic // projection routes input without focusing a stale pane. tab.clearSplitZoom() self.notificationDismissal.setSuppressesFocusFlash(true) self.focusTab( tabId, surfaceId: surfaceId, suppressFlash: true, focusPanelIdOverride: location.containerPanelID ) self.notificationDismissal.setSuppressesFocusFlash(false) _ = self.dismissNotificationOnDirectInteraction( tabId: tabId, surfaceId: surfaceId ) completion?(true) } if !accepted { if pendingProjectedNotificationFocusRequestID == requestID { pendingProjectedNotificationFocusRequestID = nil } return false } return true } // Jump-to-unread should reveal the destination pane instead of keeping an old split-zoom // state active around it. guard effectIsCurrent() else { completion?(false) return false } tab.clearSplitZoom() notificationDismissal.setSuppressesFocusFlash(true) focusTab(tabId, surfaceId: surfaceId ?? desiredPanelId, suppressFlash: true) notificationDismissal.setSuppressesFocusFlash(false) if let dismissalSurfaceID = surfaceId ?? desiredPanelId ?? tab.focusedPanelId { _ = dismissNotificationOnDirectInteraction( tabId: tabId, surfaceId: dismissalSurfaceID ) } completion?(true) return true } func focusSurface(tabId: UUID, surfaceId: UUID, focusTransactionId: UUID? = nil) { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } tab.focusPanel( tab.surfaceOwnershipTarget(for: surfaceId)?.surfaceID ?? surfaceId, focusTransactionId: focusTransactionId ) } func panelId(forSurfaceOrPanelId surfaceOrPanelId: UUID, in workspace: Workspace) -> UUID? { workspace.surfaceOwnershipTarget(for: surfaceOrPanelId)?.containerPanelID } func selectNextTab(scope: WorkspaceCycleScope = .window) { cycleWorkspace(direction: .next, scope: scope) } func selectPreviousTab(scope: WorkspaceCycleScope = .window) { cycleWorkspace(direction: .previous, scope: scope) } private func cycleWorkspace( direction: WorkspaceCycleDirection, scope: WorkspaceCycleScope ) { guard let currentId = selectedTabId, let destinationId = workspaces.cycleDestination( from: currentId, direction: direction, scope: scope ) else { return } #if DEBUG let directionLabel = switch direction { case .next: "next" case .previous: "prev" } debugPrepareWorkspaceSwitch(directionLabel, from: currentId, to: destinationId) #endif selectWorkspaceId( destinationId, notificationDismissalContext: .explicitWorkspaceResume ) // Keyboard nav is an explicit "focus one workspace" gesture, so drop // any stale sidebar multi-selection (Shift-click range) so subsequent // batch actions don't operate on workspaces the user thought they // had unselected by moving on. clearSidebarMultiSelection(except: destinationId) } /// Reduce sidebar multi-selection to a single workspace (or clear if /// `except` isn't a known tab). Called from keyboard-nav paths so a /// stale Shift-click range doesn't survive after the user moves focus. /// Posts the should-collapse event so the SwiftUI binding /// in ContentView (a @State Set separate from this tab manager) /// can collapse to the focused workspace too. private func clearSidebarMultiSelection(except workspaceId: UUID) { sidebarMultiSelection.collapseSelection( to: workspaceId, isKnownWorkspace: tabs.contains(where: { $0.id == workspaceId }) ) } #if DEBUG func debugCurrentWorkspaceSwitchSnapshot() -> (id: UInt64, startedAt: CFTimeInterval)? { guard debugWorkspaceSwitchId > 0, debugWorkspaceSwitchStartTime > 0 else { return nil } return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime) } func debugPrimeWorkspaceSwitchTrigger(_ trigger: String, to target: UUID?) { guard selectedTabId != target else { debugPendingWorkspaceSwitchTrigger = nil debugPendingWorkspaceSwitchTarget = nil return } debugPendingWorkspaceSwitchTrigger = trigger debugPendingWorkspaceSwitchTarget = target } private func debugPrepareWorkspaceSwitch(_ trigger: String, from: UUID?, to: UUID?) { guard from != to else { debugPendingWorkspaceSwitchTrigger = nil debugPendingWorkspaceSwitchTarget = nil debugPreparedWorkspaceSwitchTarget = nil return } debugPendingWorkspaceSwitchTrigger = nil debugPendingWorkspaceSwitchTarget = nil debugBeginWorkspaceSwitch(trigger: trigger, from: from, to: to) debugPreparedWorkspaceSwitchTarget = to } private func debugBeginWorkspaceSwitch(trigger: String, from: UUID?, to: UUID?) { debugWorkspaceSwitchCounter &+= 1 debugWorkspaceSwitchId = debugWorkspaceSwitchCounter debugWorkspaceSwitchStartTime = CACurrentMediaTime() cmuxDebugLog( "ws.switch.begin id=\(debugWorkspaceSwitchId) trigger=\(trigger) " + "from=\(Self.debugShortWorkspaceId(from)) to=\(Self.debugShortWorkspaceId(to)) " + "tabs=\(tabs.count)" ) } private static func debugShortWorkspaceId(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(5)) } private static func debugTitlePreview(_ title: String, limit: Int = 120) -> String { let escaped = title .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\n", with: "\\n") .replacingOccurrences(of: "\r", with: "\\r") .replacingOccurrences(of: "\t", with: "\\t") .replacingOccurrences(of: "\"", with: "\\\"") guard escaped.count > limit else { return escaped } return "\(escaped.prefix(limit))..." } private static func debugMsText(_ ms: Double) -> String { String(format: "%.2fms", ms) } #endif func selectTab(at index: Int) { guard index >= 0 && index < tabs.count else { return } #if DEBUG debugPrimeWorkspaceSwitchTrigger("select_index", to: tabs[index].id) #endif selectWorkspaceId(tabs[index].id, notificationDismissalContext: .explicitWorkspaceResume) } func selectLastTab() { guard let lastTab = tabs.last else { return } selectWorkspaceId(lastTab.id, notificationDismissalContext: .explicitWorkspaceResume) } // MARK: - Surface Navigation /// Select the next surface in the currently focused pane of the selected workspace func selectNextSurface() { selectedWorkspace?.selectNextSurface() } /// Select the previous surface in the currently focused pane of the selected workspace func selectPreviousSurface() { selectedWorkspace?.selectPreviousSurface() } /// Select a surface by index in the currently focused pane of the selected workspace func selectSurface(at index: Int) { selectedWorkspace?.selectSurface(at: index) } /// Select the last surface in the currently focused pane of the selected workspace func selectLastSurface() { selectedWorkspace?.selectLastSurface() } /// Create a new terminal surface in the focused pane of the selected workspace func newSurface() { AppDelegate.shared?.leaveChiefHome(in: self) // Cmd+T should always focus the newly created surface. selectedWorkspace?.clearSplitZoom() selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true) } func newSurface(initialInput: String) { AppDelegate.shared?.leaveChiefHome(in: self) selectedWorkspace?.clearSplitZoom() selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true, initialInput: initialInput) } // MARK: - Split Creation /// Create a new split in the current tab @discardableResult func createSplit(direction: SplitDirection) -> UUID? { createSplitOutcome(direction: direction).panel?.id } /// Create a new split from an explicit source panel. @discardableResult func createSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { createSplitOutcome(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus).panel?.id } /// Create a new browser split from the currently focused panel. @discardableResult func createBrowserSplit(direction: SplitDirection, url: URL? = nil) -> UUID? { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return nil } tab.clearSplitZoom() return newBrowserSplit( tabId: selectedTabId, fromPanelId: focusedPanelId, orientation: direction.orientation, insertFirst: direction.insertFirst, url: url ) } /// Refresh Bonsplit right-side action button tooltips for all workspaces. func refreshSplitButtonTooltips() { for workspace in tabs { workspace.refreshSplitButtonTooltips() } } func refreshTabCloseButtonVisibility() { for workspace in tabs { workspace.refreshTabCloseButtonVisibility() } } /// Re-applies `app.tabBarVisibility` to every live split controller: /// workspace panes, per-workspace Docks, and window-scope Docks. func refreshTabBarVisibility() { for workspace in tabs { workspace.refreshTabBarVisibility() workspace._dockSplit?.refreshTabBarVisibility() } for dockStore in liveWindowDockStores { dockStore.refreshTabBarVisibility() } } func applySurfaceTabBarButtons( _ buttons: [CmuxSurfaceTabBarButton], sourcePath: String?, globalConfigPath: String, settingPresets: [String: CmuxSettingValue] = [:], terminalCommandSourcePaths: [String: String], workspaceCommands: [String: CmuxResolvedCommand] ) { for workspace in tabs { workspace.applySurfaceTabBarButtons( buttons, sourcePath: sourcePath, globalConfigPath: globalConfigPath, settingPresets: settingPresets, terminalCommandSourcePaths: terminalCommandSourcePaths, workspaceCommands: workspaceCommands ) } } // MARK: - Pane Focus Navigation /// Move focus to an adjacent pane in the specified direction func movePaneFocus(direction: NavigationDirection) { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }) else { return } tab.moveFocus(direction: direction) } /// Cycle focus through panes in tree order, wrapping at the ends. @discardableResult func cyclePaneFocus(forward: Bool) -> Bool { guard let selectedTabId else { return false } return cycleSplitFocus(tabId: selectedTabId, forward: forward) } // MARK: - Focus History Navigation (CmuxWorkspaceNavigation) // The back/forward stack, suppression depth, and navigation logic live // in FocusHistoryModel; these forwarders keep every existing entrypoint // (menus, shortcuts, titlebar buttons, socket commands) unchanged. @discardableResult func withFocusHistoryRecordingSuppressed(_ body: () throws -> Result) rethrows -> Result { try focusHistoryNavigation.withFocusHistoryRecordingSuppressed(body) } func invalidateFocusHistoryTarget(workspaceId: UUID, panelId: UUID?) { focusHistoryNavigation.invalidateFocusHistoryTarget(workspaceId: workspaceId, panelId: panelId) } private func panelIdForFocusHistorySurface(_ surfaceId: UUID, workspaceId: UUID) -> UUID { guard let workspace = tabs.first(where: { $0.id == workspaceId }) else { return surfaceId } return panelId(forSurfaceOrPanelId: surfaceId, in: workspace) ?? surfaceId } var currentFocusHistoryEntry: FocusHistoryEntry? { focusHistoryNavigation.currentFocusHistoryEntry } func focusHistoryMenuSnapshot( direction: FocusHistoryMenuDirection, maxItemCount: Int? = nil ) -> FocusHistoryMenuSnapshot { focusHistoryNavigation.focusHistoryMenuSnapshot(direction: direction, maxItemCount: maxItemCount) } func recentlyFocusedFocusHistoryMenuItems(maxItemCount: Int) -> [FocusHistoryMenuItem] { focusHistoryNavigation.recentlyFocusedFocusHistoryMenuItems(maxItemCount: maxItemCount) } @discardableResult func navigateToFocusHistoryMenuItem(_ item: FocusHistoryMenuItem) -> Bool { focusHistoryNavigation.navigateToFocusHistoryMenuItem(item) } @discardableResult func navigateBack() -> Bool { focusHistoryNavigation.navigateBack() } @discardableResult func navigateForward() -> Bool { focusHistoryNavigation.navigateForward() } /// Toggles focus back to the position it most recently left. @discardableResult func navigateToLastFocused() -> Bool { focusHistoryNavigation.navigateToLastFocused() } var canNavigateBack: Bool { focusHistoryNavigation.canNavigateBack } var canNavigateForward: Bool { focusHistoryNavigation.canNavigateForward } // FocusHistoryHosting witnesses that touch private members // (`focusSelectedTabPanel`, the `private(set)` revision counter); the // rest of the conformance lives in TabManager+FocusHistoryHosting.swift. func focusSelectedWorkspacePanel() { focusSelectedTabPanel() } func focusHistoryRevisionDidChange() { focusHistoryRevision &+= 1 } // MARK: - Split Operations (Backwards Compatibility) /// Create a new split in the specified direction /// Returns the new panel's ID (which is also the surface ID for terminals) func newSplit( tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true, workingDirectory: String? = nil, initialCommand: String? = nil, initialInput: String? = nil, tmuxStartCommand: String? = nil, startupEnvironment: [String: String] = [:], initialDividerPosition: CGFloat? = nil, remotePTYSessionID: String? = nil ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } guard let panel = tab.newTerminalSplit( from: surfaceId, orientation: direction.orientation, insertFirst: direction.insertFirst, focus: focus, workingDirectory: workingDirectory, initialCommand: initialCommand, initialInput: initialInput, tmuxStartCommand: tmuxStartCommand, startupEnvironment: startupEnvironment, initialDividerPosition: initialDividerPosition, remotePTYSessionID: remotePTYSessionID ) else { return nil } // An explicit divider position wins over equalize-on-create. if initialDividerPosition == nil { tab.equalizeSplitsAfterCreatingSplitIfEnabled(newPanelId: panel.id) } return panel.id } /// Move focus in the specified direction func moveSplitFocus(tabId: UUID, surfaceId: UUID, direction: NavigationDirection) -> Bool { guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } return tab.moveFocus(direction: direction) } /// Cycle focus to the next or previous pane in tree order, wrapping at the ends. func cycleSplitFocus(tabId: UUID, forward: Bool) -> Bool { guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } #if DEBUG let beforePaneId = tab.bonsplitController.focusedPaneId let paneCount = tab.spatiallyOrderedPaneIds.count #endif let moved = tab.cycleFocus(forward: forward) #if DEBUG let afterPaneId = tab.bonsplitController.focusedPaneId dlog( "split.focus.cycle tab=\(tabId.uuidString.prefix(5)) " + "direction=\(forward ? "next" : "previous") panes=\(paneCount) " + "moved=\(moved ? 1 : 0) " + "from=\(beforePaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil") " + "to=\(afterPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil")" ) #endif return moved } /// Toggle zoom on a panel. func toggleSplitZoom(tabId: UUID, surfaceId: UUID) -> Bool { guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } return tab.toggleSplitZoom(panelId: surfaceId) } /// Toggle zoom for the currently focused panel in the selected workspace. @discardableResult func toggleFocusedSplitZoom() -> Bool { guard let tab = selectedWorkspace, let focusedPanelId = tab.focusedPanelId else { return false } return tab.toggleSplitZoom(panelId: focusedPanelId) } /// Toggle full-width-tab mode for the currently focused panel in the selected workspace. @discardableResult func toggleFocusedFullWidthTab() -> Bool { guard let tab = selectedWorkspace, let focusedPanelId = tab.focusedPanelId else { return false } return tab.toggleFullWidthTabMode(panelId: focusedPanelId) } /// Close a surface/panel func closeSurface(tabId: UUID, surfaceId: UUID) -> Bool { guard let tab = tabs.first(where: { $0.id == tabId }) else { return false } // Guard against stale close callbacks (e.g. child-exit can trigger multiple actions). // A stale callback must never affect unrelated panels/workspaces. guard tab.panels[surfaceId] != nil, tab.surfaceIdFromPanelId(surfaceId) != nil else { return false } tab.closePanel(surfaceId) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tabId, surfaceId: surfaceId) return true } /// Create a new browser panel in a split func newBrowserSplit( tabId: UUID, fromPanelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, url: URL? = nil, preferredProfileID: UUID? = nil, focus: Bool = true, initialDividerPosition: CGFloat? = nil ) -> UUID? { guard BrowserAvailabilitySettings.isEnabled() else { return nil } guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } guard let panel = tab.newBrowserSplit( from: fromPanelId, orientation: orientation, insertFirst: insertFirst, url: url, preferredProfileID: preferredProfileID, focus: focus, initialDividerPosition: initialDividerPosition ) else { return nil } // An explicit divider position wins over equalize-on-create. if initialDividerPosition == nil { tab.equalizeSplitsAfterCreatingSplitIfEnabled(newPanelId: panel.id) } return panel.id } /// Create a new browser surface in a pane func newBrowserSurface( tabId: UUID, inPane paneId: PaneID, url: URL? = nil, preferredProfileID: UUID? = nil ) -> UUID? { guard BrowserAvailabilitySettings.isEnabled() else { return nil } guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } return tab.newBrowserSurface( inPane: paneId, url: url, preferredProfileID: preferredProfileID )?.id } /// Get a browser panel by ID func browserPanel(tabId: UUID, panelId: UUID) -> BrowserPanel? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } return tab.browserPanel(for: panelId) } /// Open a browser in a specific workspace, optionally preferring a split-right layout. @discardableResult func openBrowser( inWorkspace tabId: UUID, url: URL? = nil, preferSplitRight: Bool = false, preferredProfileID: UUID? = nil, insertAtEnd: Bool = false ) -> UUID? { guard BrowserAvailabilitySettings.isEnabled() else { return nil } guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil } if selectedTabId != tabId { selectWorkspaceId(tabId, notificationDismissalContext: .explicitWorkspaceResume) } if preferSplitRight { if let targetPaneId = workspace.topRightBrowserReusePane(), let browserPanel = workspace.newBrowserSurface( inPane: targetPaneId, url: url, focus: true, insertAtEnd: insertAtEnd, preferredProfileID: preferredProfileID ) { rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) return browserPanel.id } let splitSourcePanelId: UUID? = { if let focusedPanelId = workspace.focusedPanelId, workspace.panels[focusedPanelId] != nil { return focusedPanelId } if let rememberedPanelId = lastFocusedPanelByTab[tabId], workspace.panels[rememberedPanelId] != nil { return rememberedPanelId } if let orderedPanelId = workspace.sidebarOrderedPanelIds().first(where: { workspace.panels[$0] != nil }) { return orderedPanelId } return workspace.panels.keys.sorted { $0.uuidString < $1.uuidString }.first }() if let splitSourcePanelId, let browserPanel = workspace.newBrowserSplit( from: splitSourcePanelId, orientation: .horizontal, url: url, preferredProfileID: preferredProfileID, focus: true ) { rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) return browserPanel.id } } guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first, let browserPanel = workspace.newBrowserSurface( inPane: paneId, url: url, focus: true, insertAtEnd: insertAtEnd, preferredProfileID: preferredProfileID ) else { return nil } rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id) return browserPanel.id } /// Open a browser in the currently focused pane (as a new surface) @discardableResult func openBrowser( url: URL? = nil, preferredProfileID: UUID? = nil, insertAtEnd: Bool = false ) -> UUID? { guard let tabId = selectedTabId else { return nil } return openBrowser( inWorkspace: tabId, url: url, preferSplitRight: false, preferredProfileID: preferredProfileID, insertAtEnd: insertAtEnd ) } /// Reopen the most recently closed item, retaining the legacy API name. /// Falls back to the pre-unified browser-panel history when needed. @discardableResult func reopenMostRecentlyClosedBrowserPanel() -> Bool { if reopenMostRecentlyClosedItem() { return true } return reopenMostRecentlyClosedBrowserPanelFromLegacyStack() } @discardableResult func reopenMostRecentlyClosedBrowserPanelFromLegacyStack() -> Bool { guard BrowserAvailabilitySettings.isEnabled() else { return false } while let snapshot = browserModel.popMostRecentlyClosedBrowserPanel() { // The legacy stack must restore into the workspace that originally owned the // browser. If that workspace is gone, the snapshot is stale and we drop it // instead of barging into whatever workspace happens to be selected now // (which surfaced yesterday's browser inside today's unrelated workspaces). guard let targetWorkspace = tabs.first(where: { $0.id == snapshot.workspaceId }) else { continue } let preReopenFocusedPanelId = focusedPanelId(for: targetWorkspace.id) if selectedTabId != targetWorkspace.id { selectWorkspaceId( targetWorkspace.id, notificationDismissalContext: .explicitWorkspaceResume ) } if let reopenedPanelId = reopenClosedBrowserPanel(snapshot, in: targetWorkspace) { enforceReopenedBrowserFocus( tabId: targetWorkspace.id, reopenedPanelId: reopenedPanelId, preReopenFocusedPanelId: preReopenFocusedPanelId ) return true } } return false } func clearRecentlyClosedBrowserPanelHistory() { browserModel.clearRecentlyClosedBrowserPanels() } func mostRecentLegacyClosedBrowserPanelClosedAt() -> Date? { browserModel.mostRecentClosedBrowserPanelClosedAt } @discardableResult func reopenMostRecentlyClosedItem() -> Bool { if let appDelegate = AppDelegate.shared { return appDelegate.reopenMostRecentlyClosedItem(preferredTabManager: self) } if ClosedItemHistoryStore.shared.restoreFirstRestorable(using: { entry in switch entry { case .panel(let panelEntry): return restoreClosedPanel(panelEntry) case .workspace(let workspaceEntry): return restoreClosedWorkspace(workspaceEntry) case .window: return false } }) { return true } return false } @discardableResult func reopenClosedHistoryItem(id: UUID) -> Bool { if let appDelegate = AppDelegate.shared { return appDelegate.reopenClosedHistoryItem(id: id, preferredTabManager: self) } guard let removed = ClosedItemHistoryStore.shared.removeRecord(id: id) else { return false } let didRestore: Bool switch removed.record.entry { case .panel(let panelEntry): didRestore = restoreClosedPanel(panelEntry) case .workspace(let workspaceEntry): didRestore = restoreClosedWorkspace(workspaceEntry) case .window: didRestore = false } if !didRestore { ClosedItemHistoryStore.shared.insert(removed.record, at: removed.index) } return didRestore } @discardableResult func restoreClosedPanel(_ entry: ClosedPanelHistoryEntry) -> Bool { let promptBatch = SurfaceResumeRunPromptBatch.shared promptBatch.beginRestorePass() defer { promptBatch.endRestorePass() } guard let workspace = tabs.first(where: { $0.id == entry.workspaceId }) else { return false } let preRestoreFocus = currentFocusHistoryEntry let panelId = withFocusHistoryRecordingSuppressed { workspace.restoreClosedPanel(entry, excludingStableIdentities: liveStableIdentitySet()) } guard let panelId else { return false } ClosedItemHistoryStore.shared.remapPanelAnchorIds(from: entry.snapshot.id, to: panelId) withFocusHistoryRecordingSuppressed { if selectedTabId != workspace.id { selectedTabId = workspace.id } } focusHistoryNavigation.recordFocusInHistory(preRestoreFocus, preservingForwardBranch: true) rememberFocusedSurface(tabId: workspace.id, surfaceId: panelId) focusHistoryNavigation.recordFocusInHistory(workspaceId: workspace.id, panelId: panelId, preservingForwardBranch: true) return true } @discardableResult func restoreClosedWorkspace( _ entry: ClosedWorkspaceHistoryEntry, excludingStableIdentities callerExcludedStableIdentities: Set = [], excludingWorkspaceIds callerExcludedWorkspaceIds: Set = [] ) -> Bool { guard !managedDevicePolicy.isEnforced(.disableCloud) || !Self.isCloudVMWorkspaceSnapshotForManagedPolicy(entry.snapshot) else { return false } let promptBatch = SurfaceResumeRunPromptBatch.shared promptBatch.beginRestorePass() defer { promptBatch.endRestorePass() } let preRestoreFocus = currentFocusHistoryEntry var reservedWorkspaceIds = callerExcludedWorkspaceIds reservedWorkspaceIds.formUnion(liveWorkspaceIdSet()) let sourceWorkspaceIdIsAmbiguous = reservedWorkspaceIds.contains(entry.workspaceId) var excludedStableIdentities = callerExcludedStableIdentities excludedStableIdentities.formUnion(liveStableIdentitySet()) let identitySelector = WorkspaceSessionRestoreIdentity() let restoredWorkspaceId = identitySelector.restoredWorkspaceId( persistedWorkspaceId: entry.snapshot.workspaceId, stableId: entry.snapshot.stableId, reservedWorkspaceIds: &reservedWorkspaceIds, excludingStableIdentities: excludedStableIdentities ) guard let workspace = addWorkspaceIfActive( id: restoredWorkspaceId, title: entry.snapshot.customTitle ?? entry.snapshot.processTitle, workingDirectory: entry.snapshot.currentDirectory, select: false, autoWelcomeIfNeeded: false, applyCreationTitleAsCustomTitle: false ) else { return false } let restoredPanelIds = workspace.restoreSessionSnapshot(entry.snapshot, excludingStableIdentities: excludedStableIdentities) guard !entry.snapshot.hasRestorablePanels || !restoredPanelIds.isEmpty else { closeWorkspace(workspace, recordHistory: false) return false } guard !workspace.panels.isEmpty else { closeWorkspace(workspace, recordHistory: false) return false } reconcileWorkspaceCustomization(afterRestoring: entry.snapshot, to: workspace) // The snapshot may carry a groupId for a group that no longer exists // in this TabManager (e.g. the group was dissolved between close and // reopen). Drop those stale references so the restored workspace // doesn't render as an orphaned indented row under no header. if let groupId = workspace.groupId, !workspaceGroups.contains(where: { $0.id == groupId }) { workspace.groupId = nil } // When the group DOES still exist, the workspace is about to be // reinserted at its old absolute index, which may now sit inside a // different group section after intervening reorders. Renormalize // so the restored member lands beside its group. let needsNormalize = workspace.groupId != nil && !workspaceGroups.isEmpty if !sourceWorkspaceIdIsAmbiguous { ClosedItemHistoryStore.shared.remapPanelWorkspaceIds( from: entry.workspaceId, to: workspace.id, panelIdMap: restoredPanelIds ) } if let currentIndex = tabs.firstIndex(where: { $0.id == workspace.id }) { let removed = tabs.remove(at: currentIndex) let insertIndex = min(max(entry.workspaceIndex, 0), tabs.count) tabs.insert(removed, at: insertIndex) } if needsNormalize { workspaces.normalizeWorkspaceGroupContiguity() } withFocusHistoryRecordingSuppressed { selectedTabId = workspace.id } focusHistoryNavigation.recordFocusInHistory(preRestoreFocus, preservingForwardBranch: true) if let focusedPanelId = workspace.focusedPanelId { rememberFocusedSurface(tabId: workspace.id, surfaceId: focusedPanelId) workspace.triggerFocusFlash(panelId: focusedPanelId) focusHistoryNavigation.recordFocusInHistory(workspaceId: workspace.id, panelId: focusedPanelId, preservingForwardBranch: true) } else { focusHistoryNavigation.recordFocusInHistory(workspaceId: workspace.id, panelId: nil, preservingForwardBranch: true) } return true } private func enforceReopenedBrowserFocus( tabId: UUID, reopenedPanelId: UUID, preReopenFocusedPanelId: UUID? ) { // Keep workspace-switch restoration pinned to the reopened browser panel. rememberFocusedSurface(tabId: tabId, surfaceId: reopenedPanelId) enforceReopenedBrowserFocusIfNeeded( tabId: tabId, reopenedPanelId: reopenedPanelId, preReopenFocusedPanelId: preReopenFocusedPanelId ) // Some stale focus callbacks can land one runloop turn later. Re-assert focus in two // consecutive turns, but only when focus drifted back to the pre-reopen panel. DispatchQueue.main.async { [weak self] in guard let self else { return } self.enforceReopenedBrowserFocusIfNeeded( tabId: tabId, reopenedPanelId: reopenedPanelId, preReopenFocusedPanelId: preReopenFocusedPanelId ) DispatchQueue.main.async { [weak self] in self?.enforceReopenedBrowserFocusIfNeeded( tabId: tabId, reopenedPanelId: reopenedPanelId, preReopenFocusedPanelId: preReopenFocusedPanelId ) } } } private func enforceReopenedBrowserFocusIfNeeded( tabId: UUID, reopenedPanelId: UUID, preReopenFocusedPanelId: UUID? ) { guard selectedTabId == tabId, let tab = tabs.first(where: { $0.id == tabId }), tab.panels[reopenedPanelId] != nil else { return } rememberFocusedSurface(tabId: tabId, surfaceId: reopenedPanelId) guard tab.focusedPanelId != reopenedPanelId else { return } if let focusedPanelId = tab.focusedPanelId, let preReopenFocusedPanelId, focusedPanelId != preReopenFocusedPanelId { return } tab.focusPanel(reopenedPanelId) } private func reopenClosedBrowserPanel( _ snapshot: ClosedBrowserPanelRestoreSnapshot, in workspace: Workspace ) -> UUID? { if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }), let browserPanel = workspace.newBrowserSurface( inPane: originalPane, url: snapshot.url, focus: true, preferredProfileID: snapshot.profileID ) { let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count let maxIndex = max(0, tabCount - 1) let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex) _ = workspace.reorderSurface(panelId: browserPanel.id, toIndex: targetIndex) return browserPanel.id } if let orientation = snapshot.fallbackSplitOrientation, let fallbackAnchorPaneId = snapshot.fallbackAnchorPaneId, let anchorPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == fallbackAnchorPaneId }), let anchorTab = workspace.bonsplitController.selectedTab(inPane: anchorPane) ?? workspace.bonsplitController.tabs(inPane: anchorPane).first, let anchorPanelId = workspace.panelIdFromSurfaceId(anchorTab.id), let browserPanelId = workspace.withSplitSpaceAdmissionBypass({ workspace.newBrowserSplit( from: anchorPanelId, orientation: orientation, insertFirst: snapshot.fallbackSplitInsertFirst, url: snapshot.url, preferredProfileID: snapshot.profileID )?.id }) { return browserPanelId } guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { return nil } return workspace.newBrowserSurface( inPane: focusedPane, url: snapshot.url, focus: true, preferredProfileID: snapshot.profileID )?.id } /// Flash the currently focused panel so the user can visually confirm focus. func triggerFocusFlash() { guard let tab = selectedWorkspace, let panelId = tab.focusedPanelId else { return } tab.triggerUserInitiatedFocusFlash(panelId: panelId) } /// Ensure AppKit first responder matches the currently focused terminal panel. /// This keeps real keyboard events (including Ctrl+D) on the same panel as the /// bonsplit focus indicator after rapid split topology changes. func ensureFocusedTerminalFirstResponder() { guard let tab = selectedWorkspace, let panelId = tab.focusedPanelId, let terminal = tab.terminalPanel(for: panelId) else { return } terminal.hostedView.ensureFocus(for: tab.id, surfaceId: panelId) } /// Reconcile keyboard routing before terminal control shortcuts (e.g. Ctrl+D). /// /// Source of truth for pane focus is bonsplit's focused pane + selected tab. /// Keyboard delivery must converge AppKit first responder to that model state, not mutate /// the model from whatever first responder happened to be during reparenting transitions. func reconcileFocusedPanelFromFirstResponderForKeyboard() { ensureFocusedTerminalFirstResponder() } /// Get a terminal panel by ID func terminalPanel(tabId: UUID, panelId: UUID) -> TerminalPanel? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } return tab.terminalPanel(for: panelId) } /// Get the panel for a surface ID (terminal panels use surface ID as panel ID) func surface(for tabId: UUID, surfaceId: UUID) -> TerminalSurface? { terminalPanel(tabId: tabId, panelId: surfaceId)?.surface } #if DEBUG @MainActor private func waitForWorkspacePanelsCondition( tab: Workspace, timeoutSeconds: TimeInterval, condition: @escaping (Workspace) -> Bool ) async -> Bool { guard !condition(tab) else { return true } return await withCheckedContinuation { (cont: CheckedContinuation) in var resolved = false var cancellable: AnyCancellable? func finish(_ value: Bool) { guard !resolved else { return } resolved = true cancellable?.cancel() cont.resume(returning: value) } func evaluate() { if condition(tab) { finish(true) } } cancellable = tab.panelsPublisher .map { _ in () } .sink { _ in evaluate() } DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { Task { @MainActor in finish(condition(tab)) } } evaluate() } } @MainActor private func waitForTerminalPanelCondition( tab: Workspace, panelId: UUID, timeoutSeconds: TimeInterval, condition: @escaping (TerminalPanel) -> Bool ) async -> Bool { if let panel = tab.terminalPanel(for: panelId), condition(panel) { return true } return await withCheckedContinuation { (cont: CheckedContinuation) in var resolved = false var panelsCancellable: AnyCancellable? var readyObserver: NSObjectProtocol? var hostedViewObserver: NSObjectProtocol? @MainActor func finish(_ value: Bool) { guard !resolved else { return } resolved = true panelsCancellable?.cancel() if let readyObserver { NotificationCenter.default.removeObserver(readyObserver) } if let hostedViewObserver { NotificationCenter.default.removeObserver(hostedViewObserver) } cont.resume(returning: value) } @MainActor func evaluate() { guard let panel = tab.terminalPanel(for: panelId) else { finish(false) return } panel.surface.requestBackgroundSurfaceStartIfNeeded() if condition(panel) { finish(true) } } panelsCancellable = tab.panelsPublisher .map { _ in () } .sink { _ in Task { @MainActor in evaluate() } } readyObserver = NotificationCenter.default.addObserver( forName: .terminalSurfaceDidBecomeReady, object: nil, queue: .main ) { note in guard let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, readySurfaceId == panelId else { return } Task { @MainActor in evaluate() } } hostedViewObserver = NotificationCenter.default.addObserver( forName: .terminalSurfaceHostedViewDidMoveToWindow, object: nil, queue: .main ) { note in guard let hostedSurfaceId = note.userInfo?["surfaceId"] as? UUID, hostedSurfaceId == panelId else { return } Task { @MainActor in evaluate() } } DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { Task { @MainActor in if let panel = tab.terminalPanel(for: panelId) { finish(condition(panel)) } else { finish(false) } } } evaluate() } } @MainActor private func waitForTerminalPanelReadyForUITest( tab: Workspace, panelId: UUID, timeoutSeconds: TimeInterval = 6.0 ) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) { var attached = false var hasSurface = false var firstResponder = false let _ = await waitForTerminalPanelCondition( tab: tab, panelId: panelId, timeoutSeconds: timeoutSeconds ) { panel in panel.surface.requestBackgroundSurfaceStartIfNeeded() attached = panel.surface.isViewInWindow hasSurface = panel.surface.surface != nil firstResponder = panel.hostedView.isSurfaceViewFirstResponder() return attached && hasSurface } return (attached, hasSurface, firstResponder) } private func setupUITestFocusShortcutsIfNeeded() { guard !didSetupUITestFocusShortcuts else { return } didSetupUITestFocusShortcuts = true let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_FOCUS_SHORTCUTS"] == "1" else { return } // UI tests can't record arrow keys via the shortcut recorder. Use letter-based shortcuts // so tests can reliably drive pane navigation without mouse clicks. KeyboardShortcutSettings.setShortcut( StoredShortcut(key: "h", command: true, shift: false, option: false, control: true), for: .focusLeft ) KeyboardShortcutSettings.setShortcut( StoredShortcut(key: "l", command: true, shift: false, option: false, control: true), for: .focusRight ) KeyboardShortcutSettings.setShortcut( StoredShortcut(key: "k", command: true, shift: false, option: false, control: true), for: .focusUp ) KeyboardShortcutSettings.setShortcut( StoredShortcut(key: "j", command: true, shift: false, option: false, control: true), for: .focusDown ) } private func setupSplitCloseRightUITestIfNeeded() { guard !didSetupSplitCloseRightUITest else { return } didSetupSplitCloseRightUITest = true let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] == "1" else { return } guard let path = env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"], !path.isEmpty else { return } let visualMode = env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] == "1" let shotsDir = (env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SHOTS_DIR"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let visualIterations = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] ?? "20").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 20 let burstFrames = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] ?? "6").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 6 let closeDelayMs = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] ?? "70").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 70 let pattern = (env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] ?? "close_right") .trimmingCharacters(in: .whitespacesAndNewlines) DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in guard let self else { return } Task { @MainActor [weak self] in guard let self else { return } guard let tab = self.selectedWorkspace else { self.writeSplitCloseRightTestData(["setupError": "Missing selected workspace"], at: path) return } guard let topLeftPanelId = tab.focusedPanelId else { self.writeSplitCloseRightTestData(["setupError": "Missing initial focused panel"], at: path) return } let initialTerminalReadiness = await self.waitForTerminalPanelReadyForUITest( tab: tab, panelId: topLeftPanelId ) guard initialTerminalReadiness.attached, initialTerminalReadiness.hasSurface, let terminal = tab.terminalPanel(for: topLeftPanelId) else { self.writeSplitCloseRightTestData([ "preTerminalAttached": initialTerminalReadiness.attached ? "1" : "0", "preTerminalSurfaceNil": initialTerminalReadiness.hasSurface ? "0" : "1", "setupError": "Initial terminal not ready (not attached or surface nil)" ], at: path) return } self.writeSplitCloseRightTestData([ "preTerminalAttached": "1", "preTerminalSurfaceNil": terminal.surface.surface == nil ? "1" : "0" ], at: path) if visualMode { // Visual repro mode: repeat the split/close sequence many times and write // screenshots to `shotsDir`. This avoids relying on XCUITest to click hover-only // close buttons, while still exercising the "close unfocused right tabs" path. self.writeSplitCloseRightTestData([ "visualMode": "1", "visualIterations": String(visualIterations), "visualDone": "0" ], at: path) await self.runSplitCloseRightVisualRepro( tab: tab, topLeftPanelId: topLeftPanelId, path: path, shotsDir: shotsDir, iterations: max(1, min(visualIterations, 60)), burstFrames: max(0, min(burstFrames, 80)), closeDelayMs: max(0, min(closeDelayMs, 500)), pattern: pattern ) self.writeSplitCloseRightTestData(["visualDone": "1"], at: path) return } // Layout goal: 2x2 grid (2 top, 2 bottom), then close both right panels. // Order matters: split down first, then split right in each row (matches UI shortcut repro). guard let bottomLeft = tab.newTerminalSplit(from: topLeftPanelId, orientation: .vertical) else { self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-left split"], at: path) return } guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else { self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-right split"], at: path) return } tab.focusPanel(topLeftPanelId) guard let topRight = tab.newTerminalSplit(from: topLeftPanelId, orientation: .horizontal) else { self.writeSplitCloseRightTestData(["setupError": "Failed to create top-right split"], at: path) return } self.writeSplitCloseRightTestData([ "tabId": tab.id.uuidString, "topLeftPanelId": topLeftPanelId.uuidString, "bottomLeftPanelId": bottomLeft.id.uuidString, "topRightPanelId": topRight.id.uuidString, "bottomRightPanelId": bottomRight.id.uuidString, "createdPaneCount": String(tab.bonsplitController.allPaneIds.count), "createdPanelCount": String(tab.panels.count) ], at: path) DebugUIEventCounters.resetEmptyPanelAppearCount() // Close the two right panes via the same path as the Close Tab shortcut. tab.focusPanel(topRight.id) tab.closePanel(topRight.id, force: true) tab.focusPanel(bottomRight.id) tab.closePanel(bottomRight.id, force: true) // Capture final state after Bonsplit/AppKit/Ghostty geometry reconciliation. // We avoid sleep-based timing and converge over a few main-actor turns. @MainActor func collectSplitCloseRightState() -> (data: [String: String], settled: Bool) { let paneIds = tab.bonsplitController.allPaneIds let bonsplitTabCount = tab.bonsplitController.allTabIds.count let panelCount = tab.panels.count var missingSelectedTabCount = 0 var missingPanelMappingCount = 0 var selectedTerminalCount = 0 var selectedTerminalAttachedCount = 0 var selectedTerminalZeroSizeCount = 0 var selectedTerminalSurfaceNilCount = 0 for paneId in paneIds { guard let selected = tab.bonsplitController.selectedTab(inPane: paneId) else { missingSelectedTabCount += 1 continue } guard let panel = tab.panel(for: selected.id) else { missingPanelMappingCount += 1 continue } if let terminal = panel as? TerminalPanel { selectedTerminalCount += 1 if terminal.surface.isViewInWindow { selectedTerminalAttachedCount += 1 } let size = terminal.hostedView.bounds.size if size.width < 5 || size.height < 5 { selectedTerminalZeroSizeCount += 1 } if terminal.surface.surface == nil { selectedTerminalSurfaceNilCount += 1 } } } let settled = paneIds.count == 2 && missingSelectedTabCount == 0 && missingPanelMappingCount == 0 && DebugUIEventCounters.emptyPanelAppearCount == 0 && selectedTerminalCount == 2 && selectedTerminalAttachedCount == 2 && selectedTerminalZeroSizeCount == 0 && selectedTerminalSurfaceNilCount == 0 return ( data: [ "finalPaneCount": String(paneIds.count), "finalBonsplitTabCount": String(bonsplitTabCount), "finalPanelCount": String(panelCount), "missingSelectedTabCount": String(missingSelectedTabCount), "missingPanelMappingCount": String(missingPanelMappingCount), "emptyPanelAppearCount": String(DebugUIEventCounters.emptyPanelAppearCount), "selectedTerminalCount": String(selectedTerminalCount), "selectedTerminalAttachedCount": String(selectedTerminalAttachedCount), "selectedTerminalZeroSizeCount": String(selectedTerminalZeroSizeCount), "selectedTerminalSurfaceNilCount": String(selectedTerminalSurfaceNilCount), ], settled: settled ) } @MainActor func reconcileVisibleTerminalGeometry() { NSApp.windows.forEach { window in window.contentView?.layoutSubtreeIfNeeded() window.contentView?.displayIfNeeded() } for paneId in tab.bonsplitController.allPaneIds { guard let selected = tab.bonsplitController.selectedTab(inPane: paneId), let terminal = tab.panel(for: selected.id) as? TerminalPanel else { continue } terminal.hostedView.reconcileGeometryNow() terminal.surface.forceRefresh() } } var finalState = collectSplitCloseRightState() for attempt in 1...8 { reconcileVisibleTerminalGeometry() await Task.yield() finalState = collectSplitCloseRightState() var payload = finalState.data payload["finalAttempt"] = String(attempt) self.writeSplitCloseRightTestData(payload, at: path) if finalState.settled { break } } } } } @MainActor private func runSplitCloseRightVisualRepro( tab: Workspace, topLeftPanelId: UUID, path: String, shotsDir: String, iterations: Int, burstFrames: Int, closeDelayMs: Int, pattern: String ) async { _ = shotsDir // legacy: screenshots removed in favor of IOSurface sampling func sendText(_ panelId: UUID, _ text: String) { guard let tp = tab.terminalPanel(for: panelId) else { return } tp.sendText(text) } // Sample a very top strip so the probe remains valid even after vertical expand/collapse. // We pin marker text to row 1 before each close sequence. let sampleCrop = CGRect(x: 0.04, y: 0.01, width: 0.92, height: 0.08) for i in 1...iterations { // Reset to a single pane: close everything except the top-left panel. tab.focusPanel(topLeftPanelId) let toClose = Array(tab.panels.keys).filter { $0 != topLeftPanelId } for pid in toClose { tab.closePanel(pid, force: true) } // Create the repro layout. Most patterns use a 2x2 grid, but keep a single-split // variant for the exact "close right in a horizontal pair" user report. let topLeftId = topLeftPanelId let topRight: TerminalPanel var bottomLeft: TerminalPanel? var bottomRight: TerminalPanel? switch pattern { case "close_right_single": guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) return } topRight = tr case "close_right_lrtd", "close_right_lrtd_bottom_first", "close_right_bottom_first", "close_right_lrtd_unfocused": // User repro: split left/right first, then split top/down in each column. guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) return } guard let bl = tab.newTerminalSplit(from: topLeftId, orientation: .vertical) else { writeSplitCloseRightTestData(["setupError": "Failed to split down from left (iteration \(i))"], at: path) return } guard let br = tab.newTerminalSplit(from: tr.id, orientation: .vertical) else { writeSplitCloseRightTestData(["setupError": "Failed to split down from right (iteration \(i))"], at: path) return } topRight = tr bottomLeft = bl bottomRight = br default: // Default: split top/down first, then split left/right in each row. guard let bl = tab.newTerminalSplit(from: topLeftId, orientation: .vertical) else { writeSplitCloseRightTestData(["setupError": "Failed to split down from top-left (iteration \(i))"], at: path) return } guard let br = tab.newTerminalSplit(from: bl.id, orientation: .horizontal) else { writeSplitCloseRightTestData(["setupError": "Failed to split right from bottom-left (iteration \(i))"], at: path) return } guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else { writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path) return } topRight = tr bottomLeft = bl bottomRight = br } // Let newly created surfaces attach before priming content, so sampled panes have // stable non-blank text before the close timeline begins. try? await Task.sleep(nanoseconds: 180_000_000) // Fill left panes with visible content. sendText(topLeftId, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo CMUX_SPLIT_TOPLEFT_\(i); done; printf '\\033[HCMUX_MARKER_TOPLEFT\\n'\r") sendText(topRight.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo CMUX_SPLIT_TOPRIGHT_\(i); done; printf '\\033[HCMUX_MARKER_TOPRIGHT\\n'\r") if let bottomLeft { sendText(bottomLeft.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo CMUX_SPLIT_BOTTOMLEFT_\(i); done; printf '\\033[HCMUX_MARKER_BOTTOMLEFT\\n'\r") } if let bottomRight { sendText(bottomRight.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo CMUX_SPLIT_BOTTOMRIGHT_\(i); done; printf '\\033[HCMUX_MARKER_BOTTOMRIGHT\\n'\r") } // Give shell output a moment to paint before we start the close timeline. try? await Task.sleep(nanoseconds: 180_000_000) let desiredFrames = max(16, min(burstFrames, 60)) let closeFrame = min(6, max(1, desiredFrames / 4)) let delayFrames = max(0, Int((Double(max(0, closeDelayMs)) / 16.6667).rounded(.up))) let secondCloseFrame = min(desiredFrames - 1, closeFrame + delayFrames) var closeOrder = "" let actions: [(frame: Int, action: () -> Void)] = { switch pattern { case "close_right_single": closeOrder = "TR_ONLY" return [ (frame: closeFrame, action: { tab.focusPanel(topRight.id) tab.closePanel(topRight.id, force: true) }), ] case "close_bottom": guard let bottomRight, let bottomLeft else { return [] } closeOrder = "BR_THEN_BL" return [ (frame: closeFrame, action: { tab.focusPanel(bottomRight.id) tab.closePanel(bottomRight.id, force: true) }), (frame: secondCloseFrame, action: { tab.focusPanel(bottomLeft.id) tab.closePanel(bottomLeft.id, force: true) }), ] case "close_right_lrtd_bottom_first", "close_right_bottom_first": guard let bottomRight else { return [] } closeOrder = "BR_THEN_TR" return [ (frame: closeFrame, action: { tab.focusPanel(bottomRight.id) tab.closePanel(bottomRight.id, force: true) }), (frame: secondCloseFrame, action: { tab.focusPanel(topRight.id) tab.closePanel(topRight.id, force: true) }), ] case "close_right_lrtd_unfocused": guard let bottomRight else { return [] } closeOrder = "TR_THEN_BR_UNFOCUSED" return [ (frame: closeFrame, action: { tab.closePanel(topRight.id, force: true) }), (frame: secondCloseFrame, action: { tab.closePanel(bottomRight.id, force: true) }), ] default: guard let bottomRight else { return [] } closeOrder = "TR_THEN_BR" return [ (frame: closeFrame, action: { tab.focusPanel(topRight.id) tab.closePanel(topRight.id, force: true) }), (frame: secondCloseFrame, action: { tab.focusPanel(bottomRight.id) tab.closePanel(bottomRight.id, force: true) }), ] } }() let targets: [(label: String, view: GhosttySurfaceScrollView)] = { switch pattern { case "close_right_single": return [ ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), ] case "close_bottom": return [ ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), ("TR", topRight.surface.hostedView), ] case "close_right_lrtd_bottom_first", "close_right_bottom_first": return [ ("TR", topRight.surface.hostedView), ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), ] default: guard let bottomLeft else { return [] } return [ ("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView), ("BL", bottomLeft.surface.hostedView), ] } }() let result = await captureVsyncIOSurfaceTimeline( frameCount: desiredFrames, closeFrame: closeFrame, crop: sampleCrop, targets: targets, actions: actions ) let paneStateTrace: String = { tab.bonsplitController.allPaneIds.map { paneId in let tabs = tab.bonsplitController.tabs(inPane: paneId) let selected = tab.bonsplitController.selectedTab(inPane: paneId) let selectedId = selected.map { String(describing: $0.id) } ?? "nil" let selectedPanelId = selected.flatMap { tab.panelIdFromSurfaceId($0.id) } let selectedPanelLive: String = { guard let selected else { return "0" } return tab.panel(for: selected.id) != nil ? "1" : "0" }() let mappedCount = tabs.filter { tab.panelIdFromSurfaceId($0.id) != nil }.count let selectedPanel = selectedPanelId?.uuidString.prefix(8) ?? "nil" return "pane=\(paneId.id.uuidString.prefix(8)):tabs=\(tabs.count):mapped=\(mappedCount):selected=\(selectedId.prefix(8)):selectedPanel=\(selectedPanel):selectedLive=\(selectedPanelLive)" }.joined(separator: ";") }() writeSplitCloseRightTestData([ "pattern": pattern, "iteration": String(i), "closeDelayMs": String(closeDelayMs), "closeDelayFrames": String(delayFrames), "closeOrder": closeOrder, "timelineFrameCount": String(desiredFrames), "timelineCloseFrame": String(closeFrame), "timelineSecondCloseFrame": String(secondCloseFrame), "timelineFirstBlank": result.firstBlank.map { "\($0.label)@\($0.frame)" } ?? "", "timelineFirstSizeMismatch": result.firstSizeMismatch.map { "\($0.label)@\($0.frame):ios=\($0.ios):exp=\($0.expected)" } ?? "", "timelineTrace": result.trace.joined(separator: "|"), "timelinePaneState": paneStateTrace, "visualLastIteration": String(i), ], at: path) if let firstBlank = result.firstBlank { writeSplitCloseRightTestData([ "blankFrameSeen": "1", "blankObservedIteration": String(i), "blankObservedAt": "\(firstBlank.label)@\(firstBlank.frame)" ], at: path) return } if let firstMismatch = result.firstSizeMismatch { writeSplitCloseRightTestData([ "sizeMismatchSeen": "1", "sizeMismatchObservedIteration": String(i), "sizeMismatchObservedAt": "\(firstMismatch.label)@\(firstMismatch.frame):ios=\(firstMismatch.ios):exp=\(firstMismatch.expected)" ], at: path) return } } } @MainActor private func captureVsyncIOSurfaceTimeline( frameCount: Int, closeFrame: Int, crop: CGRect, targets: [(label: String, view: GhosttySurfaceScrollView)], actions: [(frame: Int, action: () -> Void)] = [] ) async -> (firstBlank: (label: String, frame: Int)?, firstSizeMismatch: (label: String, frame: Int, ios: String, expected: String)?, trace: [String]) { guard frameCount > 0 else { return (nil, nil, []) } let st = VsyncIOSurfaceTimelineState(frameCount: frameCount, closeFrame: closeFrame) st.scheduledActions = actions.sorted(by: { $0.frame < $1.frame }) st.nextActionIndex = 0 st.targets = targets.map { t in VsyncIOSurfaceTimelineState.Target(label: t.label, sample: { @MainActor in t.view.debugSampleIOSurface(normalizedCrop: crop) }) } let unmanaged = Unmanaged.passRetained(st) let ctx = unmanaged.toOpaque() await withCheckedContinuation { (cont: CheckedContinuation) in st.continuation = cont var link: CVDisplayLink? CVDisplayLinkCreateWithActiveCGDisplays(&link) guard let link else { st.finish() Unmanaged.fromOpaque(ctx).release() return } st.link = link CVDisplayLinkSetOutputCallback(link, cmuxVsyncIOSurfaceTimelineCallback, ctx) CVDisplayLinkStart(link) } return (st.firstBlank, st.firstSizeMismatch, st.trace) } private func writeSplitCloseRightTestData(_ updates: [String: String], at path: String) { var payload = loadSplitCloseRightTestData(at: path) for (key, value) in updates { payload[key] = value } guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } try? data.write(to: URL(fileURLWithPath: path), options: .atomic) } private func loadSplitCloseRightTestData(at path: String) -> [String: String] { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return [:] } return object } private func setupChildExitSplitUITestIfNeeded() { guard !didSetupChildExitSplitUITest else { return } didSetupChildExitSplitUITest = true let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_CHILD_EXIT_SPLIT_SETUP"] == "1" else { return } guard let path = env["CMUX_UI_TEST_CHILD_EXIT_SPLIT_PATH"], !path.isEmpty else { return } let requestedIterations = Int(env["CMUX_UI_TEST_CHILD_EXIT_SPLIT_ITERATIONS"] ?? "1") ?? 1 let iterations = max(1, min(requestedIterations, 20)) func write(_ updates: [String: String]) { var payload: [String: String] = { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return [:] } return obj }() for (k, v) in updates { payload[k] = v } guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return } try? out.write(to: URL(fileURLWithPath: path), options: .atomic) } Task { @MainActor [weak self] in guard let self else { return } // Small delay so the initial window/panel has completed first layout. try? await Task.sleep(nanoseconds: 200_000_000) guard let tab = self.selectedWorkspace else { write(["setupError": "Missing selected workspace", "done": "1"]) return } write([ "requestedIterations": String(requestedIterations), "iterations": String(iterations), "workspaceCountBefore": String(self.tabs.count), "panelCountBefore": String(tab.panels.count), "done": "0", ]) var completedIterations = 0 var timedOut = false var closedWorkspace = false for i in 1...iterations { guard self.tabs.contains(where: { $0.id == tab.id }) else { closedWorkspace = true break } guard let leftPanelId = tab.focusedPanelId ?? tab.panels.keys.first else { write(["setupError": "Missing focused panel before iteration \(i)", "done": "1"]) return } // Start each iteration from a deterministic 1x1 workspace. if tab.panels.count > 1 { for panelId in tab.panels.keys where panelId != leftPanelId { tab.closePanel(panelId, force: true) } let collapsed = await self.waitForWorkspacePanelsCondition( tab: tab, timeoutSeconds: 2.0 ) { workspace in workspace.panels.count == 1 } if !collapsed { write(["setupError": "Timed out collapsing workspace before iteration \(i)", "done": "1"]) return } } guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { write(["setupError": "Failed to create right split at iteration \(i)", "done": "1"]) return } write([ "iteration": String(i), "leftPanelId": leftPanelId.uuidString, "rightPanelId": rightPanel.id.uuidString, ]) tab.focusPanel(rightPanel.id) // Wait for the split terminal surface to be attached before sending exit. // Without this, very early writes can be dropped during initial surface creation. _ = await self.waitForTerminalPanelCondition( tab: tab, panelId: rightPanel.id, timeoutSeconds: 2.0 ) { panel in panel.surface.isViewInWindow && panel.surface.surface != nil } // Use an explicit shell exit command for deterministic child-exit behavior across // startup timing variance; this still exercises the same SHOW_CHILD_EXITED path. rightPanel.sendText("exit\r") // Wait for the right panel to close. let closed = await withCheckedContinuation { (cont: CheckedContinuation) in var cancellable: AnyCancellable? var resolved = false func finish(_ value: Bool) { guard !resolved else { return } resolved = true cancellable?.cancel() cont.resume(returning: value) } cancellable = tab.panelsPublisher .map { $0.count } .removeDuplicates() .sink { count in if count == 1 { finish(true) } } DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { finish(false) } } if !closed { timedOut = true write(["timedOutIteration": String(i)]) break } if !self.tabs.contains(where: { $0.id == tab.id }) { closedWorkspace = true write(["closedWorkspaceIteration": String(i)]) break } completedIterations = i } let workspaceStillOpen = self.tabs.contains(where: { $0.id == tab.id }) let effectiveClosedWorkspace = closedWorkspace || !workspaceStillOpen write([ "workspaceCountAfter": String(self.tabs.count), "panelCountAfter": String(tab.panels.count), "workspaceStillOpen": workspaceStillOpen ? "1" : "0", "closedWorkspace": effectiveClosedWorkspace ? "1" : "0", "timedOut": timedOut ? "1" : "0", "completedIterations": String(completedIterations), "done": "1", ]) } } private func setupChildExitKeyboardUITestIfNeeded() { guard !didSetupChildExitKeyboardUITest else { return } didSetupChildExitKeyboardUITest = true let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1" else { return } guard let path = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], !path.isEmpty else { return } let autoTrigger = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] == "1" let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1" let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") .trimmingCharacters(in: .whitespacesAndNewlines) let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d" let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d" let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") .trimmingCharacters(in: .whitespacesAndNewlines) let expectedPanelsAfter = max( 1, Int((env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] ?? "1") .trimmingCharacters(in: .whitespacesAndNewlines) ) ?? 1 ) func write(_ updates: [String: String]) { var payload: [String: String] = { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return [:] } return obj }() for (k, v) in updates { payload[k] = v } guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return } try? out.write(to: URL(fileURLWithPath: path), options: .atomic) } Task { @MainActor [weak self] in guard let self else { return } try? await Task.sleep(nanoseconds: 200_000_000) guard let tab = self.selectedWorkspace else { write(["setupError": "Missing selected workspace", "done": "1"]) return } guard let leftPanelId = tab.focusedPanelId else { write(["setupError": "Missing initial focused panel", "done": "1"]) return } guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { write(["setupError": "Failed to create right split", "done": "1"]) return } var bottomLeftPanelId = "" let topRightPanelId = rightPanel.id.uuidString var bottomRightPanelId = "" var exitPanelId = rightPanel.id if layout == "lr_left_vertical" { guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { write(["setupError": "Failed to create bottom-left split", "done": "1"]) return } bottomLeftPanelId = bottomLeft.id.uuidString } else if layout == "lrtd_close_right_then_exit_top_left" { guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { write(["setupError": "Failed to create bottom-left split", "done": "1"]) return } guard let bottomRight = tab.newTerminalSplit(from: rightPanel.id, orientation: .vertical) else { write(["setupError": "Failed to create bottom-right split", "done": "1"]) return } bottomLeftPanelId = bottomLeft.id.uuidString bottomRightPanelId = bottomRight.id.uuidString // Repro flow: with a 2x2 (left/right then top/down), close both right panes, // then trigger Ctrl+D in top-left. tab.focusPanel(rightPanel.id) tab.closePanel(rightPanel.id, force: true) tab.focusPanel(bottomRight.id) tab.closePanel(bottomRight.id, force: true) exitPanelId = leftPanelId let collapsed = await self.waitForWorkspacePanelsCondition( tab: tab, timeoutSeconds: 2.0 ) { workspace in workspace.panels.count == 2 } if !collapsed { write([ "setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)", "done": "1", ]) return } } else if layout == "tdlr_close_bottom_then_exit_top_left" { // Alternate repro flow: // 1) split top/down // 2) split left/right for each row (2x2) // 3) close both bottom panes // 4) trigger Ctrl+D in top-left guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else { write(["setupError": "Failed to create bottom-left split", "done": "1"]) return } guard let topRight = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { write(["setupError": "Failed to create top-right split", "done": "1"]) return } guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else { write(["setupError": "Failed to create bottom-right split", "done": "1"]) return } bottomLeftPanelId = bottomLeft.id.uuidString bottomRightPanelId = bottomRight.id.uuidString // Close every pane except the top row; do it one-by-one and wait for model convergence. let keepPanels: Set = [leftPanelId, topRight.id] for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) { tab.focusPanel(panelId) tab.closePanel(panelId, force: true) let closed = await self.waitForWorkspacePanelsCondition( tab: tab, timeoutSeconds: 1.0 ) { workspace in workspace.panels[panelId] == nil } if !closed { write([ "setupError": "Failed to close bottom pane \(panelId.uuidString)", "done": "1", ]) return } } exitPanelId = leftPanelId let collapsed = await self.waitForWorkspacePanelsCondition( tab: tab, timeoutSeconds: 2.0 ) { workspace in workspace.panels.count == 2 } if !collapsed { write([ "setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)", "done": "1", ]) return } } tab.focusPanel(exitPanelId) // Keep child-exit keyboard tests deterministic across user shell configs. // `exec cat` exits on a single Ctrl+D and avoids ignore-eof shell settings. if let exitPanel = tab.terminalPanel(for: exitPanelId) { exitPanel.sendText("exec cat\r") } var exitPanelAttachedBeforeCtrlD = false var exitPanelHasSurfaceBeforeCtrlD = false if !useEarlyTrigger { let readiness = await self.waitForTerminalPanelReadyForUITest( tab: tab, panelId: exitPanelId ) exitPanelAttachedBeforeCtrlD = readiness.attached exitPanelHasSurfaceBeforeCtrlD = readiness.hasSurface if !(readiness.attached && readiness.hasSurface) { write([ "exitPanelAttachedBeforeCtrlD": readiness.attached ? "1" : "0", "exitPanelHasSurfaceBeforeCtrlD": readiness.hasSurface ? "1" : "0", "setupError": "Exit panel not ready for Ctrl+D (not attached or surface nil)", "done": "1", ]) return } self.ensureFocusedTerminalFirstResponder() } else if let exitPanel = tab.terminalPanel(for: exitPanelId) { exitPanelAttachedBeforeCtrlD = exitPanel.surface.isViewInWindow exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil } let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? "" let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in guard let terminal = panel as? TerminalPanel else { return nil } return terminal.hostedView.isSurfaceViewFirstResponder() ? panelId : nil }.first?.uuidString ?? "" write([ "workspaceId": tab.id.uuidString, "leftPanelId": leftPanelId.uuidString, "rightPanelId": rightPanel.id.uuidString, "topRightPanelId": topRightPanelId, "bottomLeftPanelId": bottomLeftPanelId, "bottomRightPanelId": bottomRightPanelId, "exitPanelId": exitPanelId.uuidString, "panelCountBeforeCtrlD": String(tab.panels.count), "layout": layout, "expectedPanelsAfter": String(expectedPanelsAfter), "focusedPanelBefore": focusedPanelBefore, "firstResponderPanelBefore": firstResponderPanelBefore, "exitPanelAttachedBeforeCtrlD": exitPanelAttachedBeforeCtrlD ? "1" : "0", "exitPanelHasSurfaceBeforeCtrlD": exitPanelHasSurfaceBeforeCtrlD ? "1" : "0", "ready": "1", "done": "0", ]) var finished = false var timeoutWork: DispatchWorkItem? @MainActor func finish(_ updates: [String: String]) { guard !finished else { return } finished = true timeoutWork?.cancel() write(updates.merging(["done": "1"], uniquingKeysWith: { _, new in new })) self.uiTestCancellables.removeAll() } tab.panelsPublisher .map { $0.count } .removeDuplicates() .sink { [weak self, weak tab] count in Task { @MainActor in guard let self, let tab else { return } if count == expectedPanelsAfter { // Require the post-exit state to be stable for a short window so // we catch "close looked correct, then workspace vanished" races. try? await Task.sleep(nanoseconds: 1_200_000_000) guard tab.panels.count == expectedPanelsAfter else { return } let firstResponderPanelAfter = tab.panels.compactMap { (panelId, panel) -> UUID? in guard let terminal = panel as? TerminalPanel else { return nil } return terminal.hostedView.isSurfaceViewFirstResponder() ? panelId : nil }.first?.uuidString ?? "" finish([ "workspaceCountAfter": String(self.tabs.count), "panelCountAfter": String(tab.panels.count), "closedWorkspace": self.tabs.contains(where: { $0.id == tab.id }) ? "0" : "1", "focusedPanelAfter": tab.focusedPanelId?.uuidString ?? "", "firstResponderPanelAfter": firstResponderPanelAfter, ]) } } } .store(in: &uiTestCancellables) tabsPublisher .map { $0.contains(where: { $0.id == tab.id }) } .removeDuplicates() .sink { alive in Task { @MainActor in if !alive { finish([ "workspaceCountAfter": "0", "panelCountAfter": "0", "closedWorkspace": "1", ]) } } } .store(in: &uiTestCancellables) let work = DispatchWorkItem { finish([ "workspaceCountAfter": String(self.tabs.count), "panelCountAfter": String(tab.panels.count), "closedWorkspace": self.tabs.contains(where: { $0.id == tab.id }) ? "0" : "1", "timedOut": "1", ]) } timeoutWork = work DispatchQueue.main.asyncAfter(deadline: .now() + 8.0, execute: work) if autoTrigger { Task { @MainActor [weak tab] in guard let tab else { return } write(["autoTriggerStarted": "1"]) if triggerMode == "runtime_close_callback" { write(["autoTriggerMode": "runtime_close_callback"]) self.closePanelAfterChildExited(tabId: tab.id, surfaceId: exitPanelId) return } let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift ? [.control, .shift] : [.control] let shouldWaitForSurface = !useEarlyTrigger var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false if shouldWaitForSurface { let ready = await self.waitForTerminalPanelCondition( tab: tab, panelId: exitPanelId, timeoutSeconds: 5.0 ) { panel in attachedBeforeTrigger = panel.surface.isViewInWindow hasSurfaceBeforeTrigger = panel.surface.surface != nil return attachedBeforeTrigger && hasSurfaceBeforeTrigger } if !ready, tab.terminalPanel(for: exitPanelId) == nil { write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) return } } else if let panel = tab.terminalPanel(for: exitPanelId) { attachedBeforeTrigger = panel.surface.isViewInWindow hasSurfaceBeforeTrigger = panel.surface.surface != nil } write([ "exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0", "exitPanelHasSurfaceBeforeTrigger": hasSurfaceBeforeTrigger ? "1" : "0", ]) if shouldWaitForSurface && !(attachedBeforeTrigger && hasSurfaceBeforeTrigger) { write(["autoTriggerError": "exitPanelNotReadyBeforeTrigger"]) return } guard let panel = tab.terminalPanel(for: exitPanelId) else { write(["autoTriggerError": "missingExitPanelAtTrigger"]) return } // Exercise the real key path (ghostty_surface_key for Ctrl+D). if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { write(["autoTriggerSentCtrlDKey1": "1"]) } else { write([ "autoTriggerCtrlDKeyUnavailable": "1", "autoTriggerError": "ctrlDKeyUnavailable", ]) return } // In strict mode, never mask routing bugs with fallback writes. if strictKeyOnly { let strictModeLabel: String = { if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" } if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" } if triggerUsesShift { return "strict_ctrl_shift_d" } return "strict_ctrl_d" }() write(["autoTriggerMode": strictModeLabel]) return } // Non-strict mode keeps one additional Ctrl+D retry for startup timing variance. try? await Task.sleep(nanoseconds: 450_000_000) if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { write(["autoTriggerSentCtrlDKey2": "1"]) } } } } } #endif } extension TabManager { func sessionAutosaveFingerprint( restorableAgentIndex: RestorableAgentSessionIndex = .empty, surfaceResumeBindingIndex: SurfaceResumeBindingIndex = .empty ) -> Int { var hasher = Hasher() hasher.combine(selectedTabId) hasher.combine(tabs.count) let notificationStore = AppDelegate.shared?.notificationStore // Workspace groups participate in the session snapshot, so changes // that only touch group metadata (rename / collapse / pin a group, // or move a workspace between groups without reordering tabs) must // bump the fingerprint or the autosave timer skips the write. hasher.combine(workspaceGroups.count) for group in workspaceGroups { hasher.combine(group.id) hasher.combine(group.name) hasher.combine(group.isCollapsed) hasher.combine(group.isPinned) hasher.combine(group.anchorWorkspaceId) hasher.combine(group.isEmpty) hasher.combine(group.customColor ?? "") hasher.combine(group.iconSymbol ?? "") hasher.combine(group.externalID ?? "") hasher.combine(group.anchorWorkspaceProvenance.rawValue) } for workspace in tabs.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) { hasher.combine(workspace.id) hasher.combine(workspace.groupId) hasher.combine(workspace.focusedPanelId) hasher.combine(workspace.currentDirectory) hasher.combine(workspace.customTitle ?? "") hasher.combine(workspace.customDescription ?? "") hasher.combine(workspace.customColor ?? "") hasher.combine(workspace.isPinned) // Workspace notification mute is persisted in the session // manifest; include it in the autosave fingerprint so toggling // the menu item cannot be lost when no other workspace field // changes. hasher.combine(workspace.isMuted) hasher.combine(workspace.panels.count) hasher.combine(workspace.statusEntries.count) hasher.combine(workspace.metadataBlocks.count) hasher.combine(workspace.logEntries.count) hasher.combine(workspace.panelDirectories.count) hasher.combine(workspace.panelTitles.count) hasher.combine(workspace.panelPullRequests.count) hasher.combine(workspace.panelGitBranches.count) hasher.combine(workspace.surfaceListeningPorts.count); workspace.combineTodoStateIntoSessionAutosaveFingerprint(into: &hasher) hasher.combine(notificationStore?.hasManualUnread(forTabId: workspace.id) ?? false) hasher.combine(notificationStore?.workspaceIsUnread(forTabId: workspace.id) ?? false) Self.hashNotifications( notificationStore?.notifications(forTabId: workspace.id, surfaceId: nil) ?? [], into: &hasher ) let panelIds = workspace.panels.keys.sorted { $0.uuidString < $1.uuidString } hasher.combine(panelIds.count) for panelId in panelIds { hasher.combine(panelId) hasher.combine(workspace.panelDirectories[panelId] ?? "") hasher.combine(workspace.remoteDirectoryReportPanelIds.contains(panelId)) hasher.combine(workspace.remoteDirectoryTrustRequiredPanelIds.contains(panelId)) hasher.combine(workspace.manualUnreadPanelIds.contains(panelId)) hasher.combine(workspace.restoredUnreadPanelIds.contains(panelId)) hasher.combine(workspace.restoredUnreadIndicatorContributesToWorkspace(panelId: panelId)) hasher.combine( notificationStore?.hasVisibleNotificationIndicator( forTabId: workspace.id, surfaceId: panelId ) ?? false ) Self.hashNotifications( notificationStore?.notifications(forTabId: workspace.id, surfaceId: panelId) ?? [], into: &hasher ) Self.hashRestorableAgentSnapshot( restorableAgentIndex.snapshot( workspaceId: workspace.id, panelId: panelId ), into: &hasher ) hasher.combine( restorableAgentIndex.entry( workspaceId: workspace.id, panelId: panelId )?.processLiveness ) Self.hashAgentHibernationPanelState( (workspace.panels[panelId] as? TerminalPanel)?.agentHibernationState, into: &hasher ) Self.hashSurfaceResumeBindingSnapshot( workspace.effectiveSurfaceResumeBinding( panelId: panelId, surfaceResumeBindingIndex: surfaceResumeBindingIndex ), into: &hasher ) if let terminalPanel = workspace.terminalPanel(for: panelId) { Self.hashTextBoxDraftSnapshot( terminalPanel.sessionTextBoxDraftSnapshot(), into: &hasher ) } else { hasher.combine(false) } } if let progress = workspace.progress { hasher.combine(Int((progress.value * 1000).rounded())) hasher.combine(progress.label) } else { hasher.combine(-1) } if let gitBranch = workspace.gitBranch { hasher.combine(gitBranch.branch) hasher.combine(gitBranch.isDirty) } else { hasher.combine("") hasher.combine(false) } } return hasher.finalize() } nonisolated static func restorableAgentSnapshotFingerprint( _ snapshot: SessionRestorableAgentSnapshot? ) -> Int { var hasher = Hasher() hashRestorableAgentSnapshot(snapshot, into: &hasher) return hasher.finalize() } nonisolated private static func hashRestorableAgentSnapshot( _ snapshot: SessionRestorableAgentSnapshot?, into hasher: inout Hasher ) { guard let snapshot else { hasher.combine(false) return } hasher.combine(true) hasher.combine(snapshot.kind.rawValue) hasher.combine(snapshot.sessionId) hashOptionalString(snapshot.workingDirectory, into: &hasher) hashAgentLaunchCommand(snapshot.launchCommand, into: &hasher) } nonisolated private static func hashAgentLaunchCommand( _ launchCommand: AgentLaunchCommandSnapshot?, into hasher: inout Hasher ) { guard let launchCommand else { hasher.combine(false) return } hasher.combine(true) hashOptionalString(launchCommand.launcher, into: &hasher) hashOptionalString(launchCommand.externalLauncher, into: &hasher) hashOptionalString(launchCommand.executablePath, into: &hasher) hasher.combine(launchCommand.arguments) hashOptionalString(launchCommand.workingDirectory, into: &hasher) hashOptionalString(launchCommand.verificationHome, into: &hasher) hasher.combine(launchCommand.launcherPrefix) if let environment = launchCommand.environment { hasher.combine(true) hasher.combine(environment.count) for key in environment.keys.sorted() { hasher.combine(key) hasher.combine(environment[key]) } } else { hasher.combine(false) } hashOptionalDouble(launchCommand.capturedAt, into: &hasher) hashOptionalString(launchCommand.source, into: &hasher) } private static func hashAgentHibernationPanelState( _ state: AgentHibernationPanelState?, into hasher: inout Hasher ) { guard let state else { hasher.combine(false) return } hasher.combine(true) hashRestorableAgentSnapshot(state.agent, into: &hasher) hasher.combine(state.hibernatedAt.timeIntervalSince1970) hasher.combine(state.lastActivityAt.timeIntervalSince1970) } nonisolated private static func hashSurfaceResumeBindingSnapshot( _ snapshot: SurfaceResumeBindingSnapshot?, into hasher: inout Hasher ) { guard let snapshot else { hasher.combine(false) return } hasher.combine(true) hashOptionalString(snapshot.name, into: &hasher) hashOptionalString(snapshot.kind, into: &hasher) hasher.combine(snapshot.command) hashOptionalString(snapshot.cwd, into: &hasher) hashOptionalString(snapshot.checkpointId, into: &hasher) hashOptionalString(snapshot.source, into: &hasher) hashStringMap(snapshot.environment, into: &hasher) hashAgentLaunchCommand(snapshot.launchCommand, into: &hasher) hashOptionalString(snapshot.permissionMode, into: &hasher) hashOptionalString(snapshot.resumeEvidenceProvenance, into: &hasher) hasher.combine(snapshot.allowsAutomaticResume) hasher.combine(snapshot.launchFlavor) if snapshot.isProcessDetected { hasher.combine(false) } else { hashOptionalDouble(snapshot.updatedAt, into: &hasher) } } nonisolated private static func hashTextBoxDraftSnapshot( _ snapshot: SessionTextBoxInputDraftSnapshot?, into hasher: inout Hasher ) { guard let snapshot else { hasher.combine(false) return } hasher.combine(true) hasher.combine(snapshot.isActive) hasher.combine(snapshot.parts.count) for part in snapshot.parts { hasher.combine(part.kind.rawValue) hashOptionalString(part.text, into: &hasher) hashTextBoxAttachmentSnapshot(part.attachment, into: &hasher) } } nonisolated private static func hashTextBoxAttachmentSnapshot( _ snapshot: SessionTextBoxInputAttachmentSnapshot?, into hasher: inout Hasher ) { guard let snapshot else { hasher.combine(false) return } hasher.combine(true) hasher.combine(snapshot.displayName) hasher.combine(snapshot.submissionText) hasher.combine(snapshot.submissionPath) hashOptionalString(snapshot.localPath, into: &hasher) hasher.combine(snapshot.cleanupLocalPathWhenDisposed) } nonisolated private static func hashOptionalString(_ value: String?, into hasher: inout Hasher) { if let value { hasher.combine(true) hasher.combine(value) } else { hasher.combine(false) } } nonisolated private static func hashOptionalDouble(_ value: Double?, into hasher: inout Hasher) { if let value { hasher.combine(true) hasher.combine(value) } else { hasher.combine(false) } } nonisolated private static func hashStringMap(_ value: [String: String]?, into hasher: inout Hasher) { guard let value, !value.isEmpty else { hasher.combine(false) return } hasher.combine(true) let keys = value.keys.sorted() hasher.combine(keys.count) for key in keys { hasher.combine(key) hasher.combine(value[key] ?? "") } } func sessionSnapshot( includeScrollback: Bool, restorableAgentIndex: RestorableAgentSessionIndex = .empty, surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil, downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: Bool = false ) -> SessionTabManagerSnapshot { panelTitleUpdateCoalescer.flushNow() let restorableTabs = tabs .filter(\.isRestorableInSessionSnapshot) .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) let workspaceSnapshots = restorableTabs .map { $0.sessionSnapshot( includeScrollback: includeScrollback, restorableAgentIndex: restorableAgentIndex, surfaceResumeBindingIndex: surfaceResumeBindingIndex, downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable ) } let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in restorableTabs.firstIndex(where: { $0.id == selectedTabId }) } let occupiedGroupIds = Set(restorableTabs.compactMap(\.groupId)) // Build a per-group ordered list of restorable member IDs so we can // record the anchor's index (restore-stable across UUID rotation). let restorableMembersByGroupId: [UUID: [UUID]] = { var map: [UUID: [UUID]] = [:] for tab in restorableTabs { if let gid = tab.groupId { map[gid, default: []].append(tab.id) } } return map }() let groupSnapshots: [SessionWorkspaceGroupSnapshot]? = { let snapshots = workspaceGroups .filter { group in occupiedGroupIds.contains(group.id) || (group.isPinned && group.isEmpty) } .map { group in let memberIds = restorableMembersByGroupId[group.id] ?? [] let anchorIndex = group.liveAnchorWorkspaceId.flatMap { memberIds.firstIndex(of: $0) } return SessionWorkspaceGroupSnapshot( id: group.id, name: group.name, isCollapsed: group.isCollapsed, anchorWorkspaceId: group.anchorWorkspaceId, anchorMemberIndex: anchorIndex, anchorIsEmpty: group.isEmpty ? true : nil, isPinned: group.isPinned, customColor: group.customColor, iconSymbol: group.iconSymbol, externalID: group.externalID, anchorWorkspaceProvenance: group.anchorWorkspaceProvenance.rawValue ) } return snapshots.isEmpty ? nil : snapshots }() return SessionTabManagerSnapshot( selectedWorkspaceIndex: selectedWorkspaceIndex, workspaces: workspaceSnapshots, workspaceGroups: groupSnapshots ) } func sessionSnapshotWorkspaceIds() -> [UUID] { Array( tabs .filter(\.isRestorableInSessionSnapshot) .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) .map(\.id) ) } private func releaseRestoredAwayWorkspace(_ workspace: Workspace, clearNotifications: Bool = true) { // Session restore replaces the bootstrap workspace objects with freshly // restored ones. Tear the old graph down after the atomic swap so late // panel/socket callbacks cannot keep mutating hidden pre-restore state. if clearNotifications { AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) } workspace.retireFromOwningTabManager() } /// - Parameter cloudDisabledByPolicy: The `DisableCloud` managed policy. /// While forced, no Cloud workspace is restored at all: a restored Cloud /// tab is a dead pane at best and an attach entrypoint at worst. static func normalizedCloudVMSessionRestoreWorkspaces( _ snapshots: S, selectedWorkspaceIndex: Int?, cloudDisabledByPolicy: Bool = ManagedDevicePolicy().isEnforced(.disableCloud) ) -> ([SessionWorkspaceSnapshot], Int?) where S.Element == SessionWorkspaceSnapshot { let snapshots = Array(snapshots) if cloudDisabledByPolicy { var indexMap: [Int: Int] = [:] var filtered: [SessionWorkspaceSnapshot] = [] for (index, snapshot) in snapshots.enumerated() where !isCloudVMWorkspaceSnapshotForManagedPolicy(snapshot) { indexMap[index] = filtered.count filtered.append(snapshot) } // Selection follows the same local workspace; a dropped Cloud // selection falls back to the first survivor (the caller creates a // fresh local workspace when nothing survives). let remappedSelection = selectedWorkspaceIndex.flatMap { indexMap[$0] } ?? (filtered.isEmpty ? nil : 0) return (filtered, remappedSelection) } let cloudIndexes = snapshots.indices.filter { isCloudVMSessionRestoreWorkspace(snapshots[$0]) } guard !cloudIndexes.isEmpty else { return (snapshots, selectedWorkspaceIndex) } let managedCloudIndexes = cloudIndexes.filter { isManagedCloudVMSessionRestoreWorkspace(snapshots[$0]) } let selectedManagedCloudIndex = selectedWorkspaceIndex.flatMap { selected in managedCloudIndexes.contains(selected) ? selected : nil } let keptCloudIndex = selectedManagedCloudIndex ?? managedCloudIndexes.first var indexMap: [Int: Int] = [:] var filtered: [SessionWorkspaceSnapshot] = [] for (index, snapshot) in snapshots.enumerated() { if cloudIndexes.contains(index), index != keptCloudIndex { continue } indexMap[index] = filtered.count filtered.append(snapshot) } let remappedSelection = selectedWorkspaceIndex.flatMap { indexMap[$0] } ?? keptCloudIndex.flatMap { indexMap[$0] } return (filtered, remappedSelection) } /// Restores all workspaces, panels, groups, and Dock state from a session snapshot. /// /// - Parameter deferBrowserPanels: Keeps restored browser tabs lightweight until /// their panes are visible, avoiding a launch-time WebKit construction burst. @discardableResult func restoreSessionSnapshot( _ snapshot: SessionTabManagerSnapshot, remapClosedPanelHistory: Bool = true, excludingStableIdentities: Set = [], excludingWorkspaceIds: Set = [], deferBrowserPanels: Bool = false, workspaceCreateIdempotencyCache: TerminalController.WorkspaceCreateIdempotencyCache? = nil ) -> [[UUID: UUID]] { guard !isFinalizedForWindowClose else { return [] } let promptBatch = SurfaceResumeRunPromptBatch.shared promptBatch.beginRestorePass() defer { promptBatch.endRestorePass() } isRestoringSessionSnapshot = true defer { isRestoringSessionSnapshot = false } let previousTabs = tabs for tab in previousTabs { unwireClosedBrowserTracking(for: tab) } ClosedItemHistoryStore.shared.removePanelRecords( forWorkspaceIds: Set(previousTabs.map(\.id)) ) sidebarGitMetadataService.resetAllWorkspaceGitProbeTracking() // Clear non-@Published state without touching tabs/selectedTabId yet. lastFocusedPanelByTab.removeAll() pendingPanelTitleUpdates.removeAll() focusHistoryNavigation.reset() focusHistoryRevision &+= 1 pendingWorkspaceUnfocusTarget = nil selectionSideEffectsGeneration &+= 1 browserModel.clearRecentlyClosedBrowserPanels() // Build locally to avoid intermediate @Published emissions (empty tabs, nil selectedTabId) that can leave SwiftUI's // mountedWorkspaceIds empty and cause a frozen blank launch state (#399). var newTabs: [Workspace] = [] var restoredPanelIdsByWorkspaceIndex: [[UUID: UUID]] = [] let (normalizedWorkspaceSnapshots, selectedWorkspaceIndex) = Self.normalizedCloudVMSessionRestoreWorkspaces( snapshot.workspaces.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow), selectedWorkspaceIndex: snapshot.selectedWorkspaceIndex, cloudDisabledByPolicy: managedDevicePolicy.isEnforced(.disableCloud) ) let workspaceSnapshots = normalizedWorkspaceSnapshots .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) prepareLegacyWorkspaceCustomizationMigration( afterRestoring: Array(workspaceSnapshots) ) let restoredCustomizations = cachedWorkspaceCustomizations( afterRestoring: Array(workspaceSnapshots) ) var restoredOriginalWorkspaceIds: [UUID?] = [] var reservedWorkspaceIds = excludingWorkspaceIds let identitySelector = WorkspaceSessionRestoreIdentity() for workspaceSnapshot in workspaceSnapshots { let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 let restoredWorkspaceId = identitySelector.restoredWorkspaceId( persistedWorkspaceId: workspaceSnapshot.workspaceId, stableId: workspaceSnapshot.stableId, reservedWorkspaceIds: &reservedWorkspaceIds, excludingStableIdentities: excludingStableIdentities ) let workspace = Workspace( id: restoredWorkspaceId, title: workspaceSnapshot.processTitle, workingDirectory: workspaceSnapshot.currentDirectory, portOrdinal: ordinal, tabDragTransferRegistry: tabDragTransferRegistry, settings: settings, sshClipboardWriteTrustStore: sshClipboardWriteTrustStore, closeTabWarningDefaults: closeTabWarningDefaults, agentChatResumeIntentRecorder: agentChatResumeIntentRecorder, fileContentChangeCoordinator: fileContentChangeCoordinator, nativeSSHConnectionBroker: nativeSSHConnectionBroker ) workspace.owningTabManager = self let restoredPanelIds = workspace.restoreSessionSnapshot( workspaceSnapshot, excludingStableIdentities: excludingStableIdentities, startupRestoreCommitOwner: .tabManagerTopology, deferBrowserPanels: deferBrowserPanels ) reconcileWorkspaceCustomization( afterRestoring: workspaceSnapshot, to: workspace, cachedCustomizations: restoredCustomizations ) Self.recordRestoredTaskCreateProvenance(for: workspace, in: workspaceCreateIdempotencyCache) wireClosedBrowserTracking(for: workspace) newTabs.append(workspace) restoredPanelIdsByWorkspaceIndex.append(restoredPanelIds) restoredOriginalWorkspaceIds.append(workspaceSnapshot.workspaceId) } if newTabs.isEmpty { let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 let fallbackWorkspaceId = identitySelector.freshWorkspaceId( reservingIn: &reservedWorkspaceIds ) let fallback = Workspace( id: fallbackWorkspaceId, title: "Terminal 1", portOrdinal: ordinal, tabDragTransferRegistry: tabDragTransferRegistry, settings: settings, sshClipboardWriteTrustStore: sshClipboardWriteTrustStore, closeTabWarningDefaults: closeTabWarningDefaults, agentChatResumeIntentRecorder: agentChatResumeIntentRecorder, fileContentChangeCoordinator: fileContentChangeCoordinator, nativeSSHConnectionBroker: nativeSSHConnectionBroker ) fallback.owningTabManager = self wireClosedBrowserTracking(for: fallback) newTabs.append(fallback) } // Determine selection before mutating @Published properties. let newSelectedId: UUID? if let selectedWorkspaceIndex, newTabs.indices.contains(selectedWorkspaceIndex) { newSelectedId = newTabs[selectedWorkspaceIndex].id } else { newSelectedId = newTabs.first?.id } // Single atomic assignment of @Published properties so SwiftUI observers // never see an intermediate state with empty tabs or nil selection. tabs = newTabs for workspace in newTabs { workspace.terminalStartupRestoreCoordinator.commitPendingRestores() } restoreWorkspaceDockSessionSnapshots( from: snapshot, excludingStableIdentities: excludingStableIdentities, deferBrowserPanels: deferBrowserPanels ) let restoredGroups: [WorkspaceGroup] = { guard let groupSnapshots = snapshot.workspaceGroups else { return [] } let workspaceIdsByGroupId: [UUID: [UUID]] = { var map: [UUID: [UUID]] = [:] for workspace in newTabs { if let gid = workspace.groupId { map[gid, default: []].append(workspace.id) } } return map }() var seen: Set = [] return groupSnapshots.compactMap { groupSnapshot in guard seen.insert(groupSnapshot.id).inserted else { return nil } let members = workspaceIdsByGroupId[groupSnapshot.id] ?? [] if members.isEmpty { // Only pinned groups are durable without a live member. // Their persisted anchor id is a stable header identity; // older snapshots that omit it fall back to the group id. guard groupSnapshot.isPinned == true else { return nil } return WorkspaceGroup( id: groupSnapshot.id, name: groupSnapshot.name, isCollapsed: groupSnapshot.isCollapsed, isPinned: true, // The group id is the durable header identity. Older // snapshots stored a former workspace id here; use // the group id on restore so header drags resolve as // group moves rather than as missing workspaces. anchor: .empty(groupSnapshot.id), customColor: groupSnapshot.customColor, iconSymbol: groupSnapshot.iconSymbol, externalID: groupSnapshot.externalID, anchorWorkspaceProvenance: WorkspaceGroupAnchorProvenance( rawValue: groupSnapshot.anchorWorkspaceProvenance ?? "" ) ?? .unknown ) } // Resolve anchor: prefer the restore-stable index, then the // persisted UUID hint. The UUID hint matches on ordinary // session restore, but duplicate/corrupt snapshots can still // mint fresh workspace ids. let anchorId: UUID = { if let index = groupSnapshot.anchorMemberIndex, members.indices.contains(index) { return members[index] } if let stored = groupSnapshot.anchorWorkspaceId, members.contains(stored) { return stored } return members[0] }() return WorkspaceGroup( id: groupSnapshot.id, name: groupSnapshot.name, isCollapsed: groupSnapshot.isCollapsed, isPinned: groupSnapshot.isPinned ?? false, anchor: .workspace(anchorId), customColor: groupSnapshot.customColor, iconSymbol: groupSnapshot.iconSymbol, externalID: groupSnapshot.externalID, anchorWorkspaceProvenance: WorkspaceGroupAnchorProvenance( rawValue: groupSnapshot.anchorWorkspaceProvenance ?? "" ) ?? .unknown ) } }() // Clear any group references on restored workspaces that no longer correspond // to a known group (older snapshots, manual edits, etc.). let knownGroupIds = Set(restoredGroups.map(\.id)) for workspace in newTabs where workspace.groupId.map({ !knownGroupIds.contains($0) }) ?? false { workspace.groupId = nil } workspaceGroups = restoredGroups selectedTabId = newSelectedId let existingIds = Set(newTabs.map(\.id)) pruneBackgroundWorkspaceLoads(existingIds: existingIds) sidebarMultiSelection.intersectSelection(with: existingIds) let restoredWorkspaceIds = Set(newTabs.map(\.id)) for workspace in previousTabs { releaseRestoredAwayWorkspace(workspace, clearNotifications: !restoredWorkspaceIds.contains(workspace.id)) } for workspace in newTabs { let terminalPanels = workspace.panels.values.compactMap { $0 as? TerminalPanel } for terminalPanel in terminalPanels { scheduleInitialWorkspaceGitMetadataRefreshIfPossible( workspaceId: workspace.id, panelId: terminalPanel.id ) } } if remapClosedPanelHistory { remapClosedPanelHistoryAfterSessionRestore( originalWorkspaceIds: restoredOriginalWorkspaceIds, restoredPanelIdsByWorkspaceIndex: restoredPanelIdsByWorkspaceIndex, ambiguousOriginalWorkspaceIds: excludingWorkspaceIds ) } if let selectedTabId { NotificationCenter.default.post( name: .ghosttyDidFocusTab, object: nil, userInfo: [GhosttyNotificationKey.tabId: selectedTabId] ) } return restoredPanelIdsByWorkspaceIndex } func remapClosedPanelHistoryAfterSessionRestore( originalWorkspaceIds: [UUID?], restoredPanelIdsByWorkspaceIndex: [[UUID: UUID]], ambiguousOriginalWorkspaceIds: Set = [] ) { let count = min(originalWorkspaceIds.count, tabs.count) guard count > 0 else { return } let originalWorkspaceIdCounts = originalWorkspaceIds.reduce(into: [UUID: Int]()) { counts, workspaceId in guard let workspaceId else { return } counts[workspaceId, default: 0] += 1 } var didRequestHistoryRemap = false for index in 0.. = [] ) { guard !originalWorkspaceIds.isEmpty else { return } let count = min(originalWorkspaceIds.count, tabs.count) guard count > 0 else { return } let originalWorkspaceIdCounts = originalWorkspaceIds.reduce(into: [UUID: Int]()) { counts, workspaceId in counts[workspaceId, default: 0] += 1 } var didRequestHistoryRemap = false for index in 0.. = ["zsh", "bash", "fish", "nu"] static func resolve( shellIntegrationEnabled: Bool, resolvedShell: String?, hasUserGhosttyCommand: Bool ) -> WelcomeBannerDelivery { guard shellIntegrationEnabled, !hasUserGhosttyCommand, let resolvedShell, integratedShellNames.contains(URL(fileURLWithPath: resolvedShell).lastPathComponent) else { return .typedCommand } return .shellStartup } @MainActor static func current(defaults: UserDefaults = .standard) -> WelcomeBannerDelivery { resolve( shellIntegrationEnabled: defaults.object(forKey: "sidebarShellIntegration") as? Bool ?? true, resolvedShell: GhosttyApp.shared.resolvedUserShell, hasUserGhosttyCommand: GhosttyApp.shared.hasUserGhosttyCommand ) } /// Resolves the startup environment for `delivery`. Shell startup writes a /// fresh token file and returns its path under ``environmentKey``; if the /// token cannot be written the launch falls back to ``typedCommand``. static func prepareLaunch( _ delivery: WelcomeBannerDelivery, tempDirectory: URL = FileManager.default.temporaryDirectory ) -> (delivery: WelcomeBannerDelivery, environment: [String: String]) { guard delivery == .shellStartup else { return (.typedCommand, [:]) } let directory = tempDirectory.appendingPathComponent(tokenDirectoryName, isDirectory: true) do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let token = directory.appendingPathComponent(UUID().uuidString, isDirectory: false) try Data().write(to: token, options: .atomic) return (.shellStartup, [environmentKey: token.path]) } catch { return (.typedCommand, [:]) } } } /// The "Don't ask again" checkbox shared by the close confirmation dialogs. /// Ticking it turns off the warnings that made the dialog appear, whichever /// button closes the dialog, like the Cmd+Q warning's checkbox. enum CloseDontAskAgainCheckbox { static func add(to alert: NSAlert, offering kinds: CloseWarningKinds) { guard !kinds.subtracting(.safety).isEmpty else { return } alert.showsSuppressionButton = true alert.suppressionButton?.title = String( localized: "dialog.close.dontAskAgain", defaultValue: "Don’t ask again" ) } static func apply(from alert: NSAlert, offering kinds: CloseWarningKinds, defaults: UserDefaults) { let dismissibleKinds = kinds.subtracting(.safety) guard !dismissibleKinds.isEmpty, alert.suppressionButton?.state == .on else { return } CloseTabWarningStore(defaults: defaults).disableWarnings(dismissibleKinds) } }