cmux/Sources/TabManager.swift

2884 lines
119 KiB
Swift

import AppKit
import SwiftUI
import Foundation
import Bonsplit
import CoreVideo
import Combine
// MARK: - Tab Type Alias for Backwards Compatibility
// The old Tab class is replaced by Workspace
typealias Tab = Workspace
enum NewWorkspacePlacement: String, CaseIterable, Identifiable {
case top
case afterCurrent
case end
var id: String { rawValue }
var displayName: String {
switch self {
case .top:
return "Top"
case .afterCurrent:
return "After current"
case .end:
return "End"
}
}
var description: String {
switch self {
case .top:
return "Insert new workspaces at the top of the list."
case .afterCurrent:
return "Insert new workspaces directly after the active workspace."
case .end:
return "Append new workspaces to the bottom of the list."
}
}
}
enum WorkspaceAutoReorderSettings {
static let key = "workspaceAutoReorderOnNotification"
static let defaultValue = true
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: key) == nil {
return defaultValue
}
return defaults.bool(forKey: key)
}
}
enum SidebarBranchLayoutSettings {
static let key = "sidebarBranchVerticalLayout"
static let defaultVerticalLayout = true
static func usesVerticalLayout(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: key) == nil {
return defaultVerticalLayout
}
return defaults.bool(forKey: key)
}
}
enum WorkspacePlacementSettings {
static let placementKey = "newWorkspacePlacement"
static let defaultPlacement: NewWorkspacePlacement = .afterCurrent
static func current(defaults: UserDefaults = .standard) -> NewWorkspacePlacement {
guard let raw = defaults.string(forKey: placementKey),
let placement = NewWorkspacePlacement(rawValue: raw) else {
return defaultPlacement
}
return placement
}
static func insertionIndex(
placement: NewWorkspacePlacement,
selectedIndex: Int?,
selectedIsPinned: Bool,
pinnedCount: Int,
totalCount: Int
) -> Int {
let clampedTotalCount = max(0, totalCount)
let clampedPinnedCount = max(0, min(pinnedCount, clampedTotalCount))
switch placement {
case .top:
// Keep pinned workspaces grouped at the top by inserting ahead of unpinned items.
return clampedPinnedCount
case .end:
return clampedTotalCount
case .afterCurrent:
guard let selectedIndex, clampedTotalCount > 0 else {
return clampedTotalCount
}
let clampedSelectedIndex = max(0, min(selectedIndex, clampedTotalCount - 1))
if selectedIsPinned {
return clampedPinnedCount
}
return min(clampedSelectedIndex + 1, clampedTotalCount)
}
}
}
/// Coalesces repeated main-thread signals into one callback after a short delay.
/// Useful for notification storms where only the latest update matters.
final class NotificationBurstCoalescer {
private let delay: TimeInterval
private var isFlushScheduled = false
private var pendingAction: (() -> Void)?
init(delay: TimeInterval = 1.0 / 30.0) {
self.delay = max(0, delay)
}
func signal(_ action: @escaping () -> Void) {
precondition(Thread.isMainThread, "NotificationBurstCoalescer must be used on the main thread")
pendingAction = action
scheduleFlushIfNeeded()
}
private func scheduleFlushIfNeeded() {
guard !isFlushScheduled else { return }
isFlushScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.flush()
}
}
private func flush() {
precondition(Thread.isMainThread, "NotificationBurstCoalescer must be used on the main thread")
isFlushScheduled = false
guard let action = pendingAction else { return }
pendingAction = nil
action()
if pendingAction != nil {
scheduleFlushIfNeeded()
}
}
}
struct RecentlyClosedBrowserStack {
private(set) var entries: [ClosedBrowserPanelRestoreSnapshot] = []
let capacity: Int
init(capacity: Int) {
self.capacity = max(1, capacity)
}
var isEmpty: Bool {
entries.isEmpty
}
mutating func push(_ snapshot: ClosedBrowserPanelRestoreSnapshot) {
entries.append(snapshot)
if entries.count > capacity {
entries.removeFirst(entries.count - capacity)
}
}
mutating func pop() -> ClosedBrowserPanelRestoreSnapshot? {
entries.popLast()
}
}
#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<Void, Never>?
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<CVTimeStamp>,
_ inOutputTime: UnsafePointer<CVTimeStamp>,
_ flagsIn: CVOptionFlags,
_ flagsOut: UnsafeMutablePointer<CVOptionFlags>,
_ ctx: UnsafeMutableRawPointer?
) -> CVReturn {
guard let ctx else { return kCVReturnSuccess }
let st = Unmanaged<VsyncIOSurfaceTimelineState>.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<VsyncIOSurfaceTimelineState>.fromOpaque(ctx).release()
}
return kCVReturnSuccess
}
#endif
@MainActor
class TabManager: ObservableObject {
@Published var tabs: [Workspace] = []
@Published private(set) var isWorkspaceCycleHot: Bool = false
/// 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).
private static var nextPortOrdinal: Int = 0
@Published var selectedTabId: UUID? {
didSet {
guard selectedTabId != oldValue else { return }
let previousTabId = oldValue
if let previousTabId,
let previousPanelId = focusedPanelId(for: previousTabId) {
lastFocusedPanelByTab[previousTabId] = previousPanelId
}
if !isNavigatingHistory, let selectedTabId {
recordTabInHistory(selectedTabId)
}
#if DEBUG
let switchId = debugWorkspaceSwitchId
let switchDtMs = debugWorkspaceSwitchStartTime > 0
? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000
: 0
dlog(
"ws.select.didSet id=\(switchId) from=\(Self.debugShortWorkspaceId(previousTabId)) " +
"to=\(Self.debugShortWorkspaceId(selectedTabId)) dt=\(Self.debugMsText(switchDtMs))"
)
#endif
selectionSideEffectsGeneration &+= 1
let generation = selectionSideEffectsGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.selectionSideEffectsGeneration == generation else { return }
self.focusSelectedTabPanel(previousTabId: previousTabId)
self.updateWindowTitleForSelectedTab()
if let selectedTabId = self.selectedTabId {
self.markFocusedPanelReadIfActive(tabId: selectedTabId)
}
#if DEBUG
let dtMs = self.debugWorkspaceSwitchStartTime > 0
? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000
: 0
dlog(
"ws.select.asyncDone id=\(self.debugWorkspaceSwitchId) dt=\(Self.debugMsText(dtMs)) " +
"selected=\(Self.debugShortWorkspaceId(self.selectedTabId))"
)
#endif
}
}
}
private var observers: [NSObjectProtocol] = []
private var suppressFocusFlash = false
private var lastFocusedPanelByTab: [UUID: UUID] = [:]
private struct PanelTitleUpdateKey: Hashable {
let tabId: UUID
let panelId: UUID
}
private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:]
private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
// Recent tab history for back/forward navigation (like browser history)
private var tabHistory: [UUID] = []
private var historyIndex: Int = -1
private var isNavigatingHistory = false
private let maxHistorySize = 50
private var selectionSideEffectsGeneration: UInt64 = 0
private var workspaceCycleGeneration: UInt64 = 0
private var workspaceCycleCooldownTask: Task<Void, Never>?
private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)?
#if DEBUG
private var debugWorkspaceSwitchCounter: UInt64 = 0
private var debugWorkspaceSwitchId: UInt64 = 0
private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0
#endif
#if DEBUG
private var didSetupSplitCloseRightUITest = false
private var didSetupUITestFocusShortcuts = false
private var didSetupChildExitSplitUITest = false
private var didSetupChildExitKeyboardUITest = false
private var uiTestCancellables = Set<AnyCancellable>()
#endif
init(initialWorkingDirectory: String? = nil) {
addWorkspace(workingDirectory: initialWorkingDirectory)
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 tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return }
guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return }
guard let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { return }
enqueuePanelTitleUpdate(tabId: tabId, panelId: surfaceId, title: title)
}
})
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 }
markPanelReadOnFocusIfActive(tabId: tabId, panelId: surfaceId)
}
})
#if DEBUG
setupUITestFocusShortcutsIfNeeded()
setupSplitCloseRightUITestIfNeeded()
setupChildExitSplitUITestIfNeeded()
setupChildExitKeyboardUITestIfNeeded()
#endif
}
deinit {
workspaceCycleCooldownTask?.cancel()
}
private func wireClosedBrowserTracking(for workspace: Workspace) {
workspace.onClosedBrowserPanel = { [weak self] snapshot in
self?.recentlyClosedBrowsers.push(snapshot)
}
}
private func unwireClosedBrowserTracking(for workspace: Workspace) {
workspace.onClosedBrowserPanel = nil
}
var selectedWorkspace: Workspace? {
guard let selectedTabId else { return nil }
return tabs.first(where: { $0.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?.focusedTerminalPanel?.surface
}
/// Returns the focused panel's terminal panel (if it is a terminal)
var selectedTerminalPanel: TerminalPanel? {
selectedWorkspace?.focusedTerminalPanel
}
var isFindVisible: Bool {
selectedTerminalPanel?.searchState != nil
}
var canUseSelectionForFind: Bool {
selectedTerminalPanel?.hasSelection() == true
}
func startSearch() {
guard let panel = selectedTerminalPanel else { return }
if panel.searchState == nil {
panel.searchState = TerminalSurface.SearchState()
}
NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("start_search")
}
func searchSelection() {
guard let panel = selectedTerminalPanel else { return }
if panel.searchState == nil {
panel.searchState = TerminalSurface.SearchState()
}
NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("search_selection")
}
func findNext() {
_ = selectedTerminalPanel?.performBindingAction("search:next")
}
func findPrevious() {
_ = selectedTerminalPanel?.performBindingAction("search:previous")
}
func hideFind() {
selectedTerminalPanel?.searchState = nil
}
@discardableResult
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace {
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal)
wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex()
if insertIndex >= 0 && insertIndex <= tabs.count {
tabs.insert(newWorkspace, at: insertIndex)
} else {
tabs.append(newWorkspace)
}
if select {
selectedTabId = newWorkspace.id
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id]
)
}
#if DEBUG
UITestRecorder.incrementInt("addTabInvocations")
UITestRecorder.record([
"tabCount": String(tabs.count),
"selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "")
])
#endif
return newWorkspace
}
// Keep addTab as convenience alias
@discardableResult
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
private func normalizedWorkingDirectory(_ directory: String?) -> String? {
guard let directory else { return nil }
let normalized = normalizeDirectory(directory)
let trimmed = normalized.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : normalized
}
private func newTabInsertIndex() -> Int {
let placement = WorkspacePlacementSettings.current()
let pinnedCount = tabs.filter { $0.isPinned }.count
let selectedIndex = selectedTabId.flatMap { tabId in
tabs.firstIndex(where: { $0.id == tabId })
}
let selectedIsPinned = selectedIndex.map { tabs[$0].isPinned } ?? false
return WorkspacePlacementSettings.insertionIndex(
placement: placement,
selectedIndex: selectedIndex,
selectedIsPinned: selectedIsPinned,
pinnedCount: pinnedCount,
totalCount: tabs.count
)
}
private func preferredWorkingDirectoryForNewTab() -> String? {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
return nil
}
let focusedDirectory = tab.focusedPanelId
.flatMap { tab.panelDirectories[$0] }
let candidate = focusedDirectory ?? tab.currentDirectory
let normalized = normalizeDirectory(candidate)
let trimmed = normalized.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : normalized
}
func moveTabToTop(_ tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
guard index != 0 else { return }
let tab = tabs.remove(at: index)
let pinnedCount = tabs.filter { $0.isPinned }.count
let insertIndex = tab.isPinned ? 0 : pinnedCount
tabs.insert(tab, at: insertIndex)
}
func moveTabsToTop(_ tabIds: Set<UUID>) {
guard !tabIds.isEmpty else { return }
let selectedTabs = tabs.filter { tabIds.contains($0.id) }
guard !selectedTabs.isEmpty else { return }
let remainingTabs = tabs.filter { !tabIds.contains($0.id) }
let selectedPinned = selectedTabs.filter { $0.isPinned }
let selectedUnpinned = selectedTabs.filter { !$0.isPinned }
let remainingPinned = remainingTabs.filter { $0.isPinned }
let remainingUnpinned = remainingTabs.filter { !$0.isPinned }
tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned
}
@discardableResult
func reorderWorkspace(tabId: UUID, toIndex targetIndex: Int) -> Bool {
guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false }
if tabs.count <= 1 { return true }
let clamped = max(0, min(targetIndex, tabs.count - 1))
if currentIndex == clamped { return true }
let workspace = tabs.remove(at: currentIndex)
tabs.insert(workspace, at: clamped)
return true
}
@discardableResult
func reorderWorkspace(tabId: UUID, before beforeId: UUID? = nil, after afterId: UUID? = nil) -> Bool {
guard tabs.contains(where: { $0.id == tabId }) else { return false }
if let beforeId {
guard let idx = tabs.firstIndex(where: { $0.id == beforeId }) else { return false }
return reorderWorkspace(tabId: tabId, toIndex: idx)
}
if let afterId {
guard let idx = tabs.firstIndex(where: { $0.id == afterId }) else { return false }
return reorderWorkspace(tabId: tabId, toIndex: idx + 1)
}
return false
}
func setCustomTitle(tabId: UUID, title: String?) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
tabs[index].setCustomTitle(title)
if selectedTabId == tabId {
updateWindowTitle(for: tabs[index])
}
}
func clearCustomTitle(tabId: UUID) {
setCustomTitle(tabId: tabId, title: nil)
}
func togglePin(tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let tab = tabs[index]
setPinned(tab, pinned: !tab.isPinned)
}
func setPinned(_ tab: Workspace, pinned: Bool) {
guard tab.isPinned != pinned else { return }
tab.isPinned = pinned
reorderTabForPinnedState(tab)
}
private func reorderTabForPinnedState(_ tab: Workspace) {
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
tabs.remove(at: index)
let pinnedCount = tabs.filter { $0.isPinned }.count
let insertIndex = min(pinnedCount, tabs.count)
tabs.insert(tab, at: insertIndex)
}
// MARK: - Surface Directory Updates (Backwards Compatibility)
func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
let normalized = normalizeDirectory(directory)
tab.updatePanelDirectory(panelId: surfaceId, directory: normalized)
}
private func normalizeDirectory(_ directory: String) -> String {
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return directory }
if trimmed.hasPrefix("file://"), let url = URL(string: trimmed) {
if !url.path.isEmpty {
return url.path
}
}
return trimmed
}
func closeWorkspace(_ workspace: Workspace) {
guard tabs.count > 1 else { return }
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
unwireClosedBrowserTracking(for: workspace)
if let index = tabs.firstIndex(where: { $0.id == workspace.id }) {
tabs.remove(at: index)
if selectedTabId == workspace.id {
// Keep the "focused index" stable when possible:
// - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up).
// - Otherwise (we closed the last workspace), focus the new last workspace (i-1).
let newIndex = min(index, max(0, tabs.count - 1))
selectedTabId = tabs[newIndex].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 }) else { return nil }
let removed = tabs.remove(at: index)
unwireClosedBrowserTracking(for: removed)
lastFocusedPanelByTab.removeValue(forKey: removed.id)
if tabs.isEmpty {
// The UI assumes each window always has at least one workspace.
_ = addWorkspace()
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) {
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)
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 let selectedId = selectedTabId,
let tab = tabs.first(where: { $0.id == selectedId }),
let focusedPanelId = tab.focusedPanelId else { return }
closePanelWithConfirmation(tab: tab, panelId: focusedPanelId)
}
func closeCurrentWorkspaceWithConfirmation() {
#if DEBUG
UITestRecorder.incrementInt("closeTabInvocations")
#endif
guard let selectedId = selectedTabId,
let workspace = tabs.first(where: { $0.id == selectedId }) else { return }
closeWorkspaceWithConfirmation(workspace)
}
func closeWorkspaceWithConfirmation(_ workspace: Workspace) {
closeWorkspaceIfRunningProcess(workspace)
}
func closeWorkspaceWithConfirmation(tabId: UUID) {
guard let workspace = tabs.first(where: { $0.id == tabId }) else { return }
closeWorkspaceWithConfirmation(workspace)
}
func selectWorkspace(_ workspace: Workspace) {
selectedTabId = workspace.id
}
// Keep selectTab as convenience alias
func selectTab(_ tab: Workspace) { selectWorkspace(tab) }
private func confirmClose(title: String, message: String, acceptCmdD: Bool) -> Bool {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save").
// We only opt into this for the "close last workspace => close window" path to avoid
// conflicting with app-level Cmd+D (split right) during normal usage.
if acceptCmdD, let closeButton = alert.buttons.first {
closeButton.keyEquivalent = "d"
closeButton.keyEquivalentModifierMask = [.command]
// Keep Return/Enter behavior by explicitly setting the default button cell.
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
}
return alert.runModal() == .alertFirstButtonReturn
}
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) {
let willCloseWindow = tabs.count <= 1
if workspaceNeedsConfirmClose(workspace),
!confirmClose(
title: "Close workspace?",
message: "This will close the workspace and all of its panels.",
acceptCmdD: willCloseWindow
) {
return
}
if tabs.count <= 1 {
// Last workspace in this window: close the window (Cmd+Shift+W behavior).
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
} else {
closeWorkspace(workspace)
}
}
private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) {
// Cmd+W closes the focused Bonsplit tab (a "tab" in the UI). When the workspace only has
// a single tab left, closing it should close the workspace (and possibly the window),
// rather than creating a replacement terminal.
let isLastTabInWorkspace = tab.panels.count <= 1
if isLastTabInWorkspace {
let willCloseWindow = tabs.count <= 1
let needsConfirm = workspaceNeedsConfirmClose(tab)
if needsConfirm {
let message = willCloseWindow
? "This will close the last tab and close the window."
: "This will close the last tab and close its workspace."
guard confirmClose(
title: "Close tab?",
message: message,
acceptCmdD: willCloseWindow
) else { return }
}
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id)
if willCloseWindow {
AppDelegate.shared?.closeMainWindowContainingTabId(tab.id)
} else {
closeWorkspace(tab)
}
return
}
if let terminalPanel = tab.terminalPanel(for: panelId),
terminalPanel.needsConfirmClose() {
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
acceptCmdD: false
) else { return }
}
// We already confirmed (if needed); bypass Bonsplit's delegate gating.
tab.closePanel(panelId, force: true)
}
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 }
guard tab.panels[surfaceId] != nil else { return }
if let terminalPanel = tab.terminalPanel(for: surfaceId),
terminalPanel.needsConfirmClose() {
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
acceptCmdD: false
) 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 }
guard tab.panels[surfaceId] != nil else { return }
// 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()
_ = tab.closePanel(surfaceId, force: true)
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId)
}
/// Close a panel because its child process exited (e.g. the user hit Ctrl+D).
///
/// This should never prompt: the process is already gone, and Ghostty emits the
/// `SHOW_CHILD_EXITED` action specifically so the host app can decide what to do.
func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
guard tab.panels[surfaceId] != nil else { return }
// Child-exit on the last panel should collapse the workspace, matching explicit close
// semantics (and close the window when it was the last workspace).
if tab.panels.count <= 1 {
if tabs.count <= 1 {
if let app = AppDelegate.shared {
app.notificationStore?.clearNotifications(forTabId: tabId)
app.closeMainWindowContainingTabId(tabId)
} else {
// Headless/test fallback when no AppDelegate window context exists.
closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId)
}
} else {
closeWorkspace(tab)
}
return
}
closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId)
}
private 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 titleForTab(_ tabId: UUID) -> String? {
tabs.first(where: { $0.id == 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
}
/// Returns the focused panel if it's a BrowserPanel, nil otherwise
var focusedBrowserPanel: BrowserPanel? {
guard let tab = selectedWorkspace,
let panelId = tab.focusedPanelId else { return nil }
return tab.panels[panelId] as? BrowserPanel
}
@discardableResult
func zoomInFocusedBrowser() -> Bool {
focusedBrowserPanel?.zoomIn() ?? false
}
@discardableResult
func zoomOutFocusedBrowser() -> Bool {
focusedBrowserPanel?.zoomOut() ?? false
}
@discardableResult
func resetZoomFocusedBrowser() -> Bool {
focusedBrowserPanel?.resetZoom() ?? false
}
@discardableResult
func toggleDeveloperToolsFocusedBrowser() -> Bool {
focusedBrowserPanel?.toggleDeveloperTools() ?? false
}
@discardableResult
func showJavaScriptConsoleFocusedBrowser() -> Bool {
focusedBrowserPanel?.showDeveloperToolsConsole() ?? false
}
/// Backwards compatibility: returns the focused surface ID
func focusedSurfaceId(for tabId: UUID) -> UUID? {
focusedPanelId(for: tabId)
}
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.focusedTerminalPanel else { return }
terminalPanel.applyWindowBackgroundIfActive()
}
private func focusSelectedTabPanel(previousTabId: UUID?) {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else { return }
// Try to restore previous focus
if let restoredPanelId = lastFocusedPanelByTab[selectedTabId],
tab.panels[restoredPanelId] != nil,
tab.focusedPanelId != restoredPanelId {
tab.focusPanel(restoredPanelId)
}
// Focus the panel
guard let panelId = tab.focusedPanelId,
let panel = tab.panels[panelId] else { return }
// Defer unfocusing the previous workspace's panel until ContentView confirms handoff
// completion (new workspace has focus or timeout fallback), to avoid a visible freeze gap.
if let previousTabId,
let previousTab = tabs.first(where: { $0.id == previousTabId }),
let previousPanelId = previousTab.focusedPanelId,
previousTab.panels[previousPanelId] != nil {
replacePendingWorkspaceUnfocusTarget(
with: (tabId: previousTabId, panelId: previousPanelId)
)
}
panel.focus()
// For terminal panels, ensure proper focus handling
if let terminalPanel = panel as? TerminalPanel {
terminalPanel.hostedView.ensureFocus(for: selectedTabId, surfaceId: 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
dlog(
"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
dlog(
"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 {
dlog(
"ws.unfocus.complete id=none tab=\(Self.debugShortWorkspaceId(pending.tabId)) " +
"panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)"
)
}
#endif
}
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
dlog(
"ws.unfocus.flush tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced"
)
#endif
} else {
#if DEBUG
dlog(
"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
dlog(
"ws.unfocus.defer id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " +
"tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))"
)
} else {
dlog(
"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
}
private func markFocusedPanelReadIfActive(tabId: UUID) {
let shouldSuppressFlash = suppressFocusFlash
suppressFocusFlash = false
guard !shouldSuppressFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let panelId = focusedPanelId(for: tabId) else { return }
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
}
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
guard selectedTabId == tabId else { return }
guard !suppressFocusFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return }
if let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
}
private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let key = PanelTitleUpdateKey(tabId: tabId, panelId: panelId)
pendingPanelTitleUpdates[key] = trimmed
panelTitleUpdateCoalescer.signal { [weak self] in
self?.flushPendingPanelTitleUpdates()
}
}
private func flushPendingPanelTitleUpdates() {
guard !pendingPanelTitleUpdates.isEmpty else { return }
let updates = pendingPanelTitleUpdates
pendingPanelTitleUpdates.removeAll(keepingCapacity: true)
for (key, title) in updates {
updatePanelTitle(tabId: key.tabId, panelId: key.panelId, title: title)
}
}
private func updatePanelTitle(tabId: UUID, panelId: UUID, title: String) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
let didChange = tab.updatePanelTitle(panelId: panelId, title: title)
guard didChange else { return }
// Update window title if this is the selected tab and focused panel
if selectedTabId == tabId && tab.focusedPanelId == panelId {
updateWindowTitle(for: tab)
}
}
func focusedSurfaceTitleDidChange(tabId: UUID) {
guard let tab = tabs.first(where: { $0.id == tabId }),
let focusedPanelId = tab.focusedPanelId,
let title = tab.panelTitles[focusedPanelId] else { return }
tab.applyProcessTitle(title)
if selectedTabId == tabId {
updateWindowTitle(for: tab)
}
}
private func updateWindowTitleForSelectedTab() {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
updateWindowTitle(for: nil)
return
}
updateWindowTitle(for: tab)
}
private func updateWindowTitle(for tab: Workspace?) {
let title = windowTitle(for: tab)
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first
targetWindow?.title = title
}
private func windowTitle(for tab: Workspace?) -> String {
guard let tab else { return "cmux" }
let trimmedTitle = tab.title.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedTitle.isEmpty {
return trimmedTitle
}
let trimmedDirectory = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedDirectory.isEmpty ? "cmux" : trimmedDirectory
}
func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) {
guard tabs.contains(where: { $0.id == tabId }) else { return }
selectedTabId = tabId
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: tabId]
)
DispatchQueue.main.async {
NSApp.activate(ignoringOtherApps: true)
NSApp.unhide(nil)
if let window = NSApp.keyWindow ?? NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
}
}
if let surfaceId {
if !suppressFlash {
focusSurface(tabId: tabId, surfaceId: surfaceId)
} else if let tab = tabs.first(where: { $0.id == tabId }) {
tab.focusPanel(surfaceId)
}
}
}
func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) {
let wasSelected = selectedTabId == tabId
let desiredPanelId = surfaceId ?? tabs.first(where: { $0.id == tabId })?.focusedPanelId
#if DEBUG
if let desiredPanelId {
AppDelegate.shared?.armJumpUnreadFocusRecord(tabId: tabId, surfaceId: desiredPanelId)
}
#endif
suppressFocusFlash = true
focusTab(tabId, surfaceId: desiredPanelId, suppressFlash: true)
if wasSelected {
suppressFocusFlash = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self,
let tab = self.tabs.first(where: { $0.id == tabId }) else { return }
let targetPanelId = desiredPanelId ?? tab.focusedPanelId
guard let targetPanelId,
tab.panels[targetPanelId] != nil else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetPanelId) else { return }
tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true)
notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId)
}
}
func focusSurface(tabId: UUID, surfaceId: UUID) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
tab.focusPanel(surfaceId)
}
func selectNextTab() {
guard let currentId = selectedTabId,
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
let nextIndex = (currentIndex + 1) % tabs.count
#if DEBUG
let nextId = tabs[nextIndex].id
debugWorkspaceSwitchCounter &+= 1
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
dlog(
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=next from=\(Self.debugShortWorkspaceId(currentId)) " +
"to=\(Self.debugShortWorkspaceId(nextId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
)
#endif
activateWorkspaceCycleHotWindow()
selectedTabId = tabs[nextIndex].id
}
func selectPreviousTab() {
guard let currentId = selectedTabId,
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count
#if DEBUG
let prevId = tabs[prevIndex].id
debugWorkspaceSwitchCounter &+= 1
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
dlog(
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=prev from=\(Self.debugShortWorkspaceId(currentId)) " +
"to=\(Self.debugShortWorkspaceId(prevId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
)
#endif
activateWorkspaceCycleHotWindow()
selectedTabId = tabs[prevIndex].id
}
private func activateWorkspaceCycleHotWindow() {
workspaceCycleGeneration &+= 1
let generation = workspaceCycleGeneration
#if DEBUG
let switchId = debugWorkspaceSwitchId
let switchDtMs = debugWorkspaceSwitchStartTime > 0
? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000
: 0
#endif
if !isWorkspaceCycleHot {
isWorkspaceCycleHot = true
#if DEBUG
dlog(
"ws.hot.on id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))"
)
#endif
}
let hadPendingCooldown = workspaceCycleCooldownTask != nil
workspaceCycleCooldownTask?.cancel()
#if DEBUG
if hadPendingCooldown {
dlog(
"ws.hot.cancelPrev id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))"
)
}
#endif
workspaceCycleCooldownTask = Task { [weak self, generation] in
do {
try await Task.sleep(nanoseconds: 220_000_000)
} catch {
#if DEBUG
await MainActor.run {
guard let self else { return }
let dtMs = self.debugWorkspaceSwitchStartTime > 0
? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000
: 0
dlog(
"ws.hot.cooldownCanceled id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))"
)
}
#endif
return
}
await MainActor.run {
guard let self else { return }
guard self.workspaceCycleGeneration == generation else { return }
#if DEBUG
let dtMs = self.debugWorkspaceSwitchStartTime > 0
? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000
: 0
dlog(
"ws.hot.off id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))"
)
#endif
self.isWorkspaceCycleHot = false
self.workspaceCycleCooldownTask = nil
}
}
}
#if DEBUG
func debugCurrentWorkspaceSwitchSnapshot() -> (id: UInt64, startedAt: CFTimeInterval)? {
guard debugWorkspaceSwitchId > 0, debugWorkspaceSwitchStartTime > 0 else { return nil }
return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime)
}
private static func debugShortWorkspaceId(_ id: UUID?) -> String {
guard let id else { return "nil" }
return String(id.uuidString.prefix(5))
}
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 }
selectedTabId = tabs[index].id
}
func selectLastTab() {
guard let lastTab = tabs.last else { return }
selectedTabId = lastTab.id
}
// 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() {
// Cmd+T should always focus the newly created surface.
selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true)
}
// MARK: - Split Creation
/// Create a new split in the current tab
func createSplit(direction: SplitDirection) {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return }
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
}
/// 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 }
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()
}
}
// 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)
}
// MARK: - Recent Tab History Navigation
private func recordTabInHistory(_ tabId: UUID) {
// If we're not at the end of history, truncate forward history
if historyIndex < tabHistory.count - 1 {
tabHistory = Array(tabHistory.prefix(historyIndex + 1))
}
// Don't add duplicate consecutive entries
if tabHistory.last == tabId {
return
}
tabHistory.append(tabId)
// Trim history if it exceeds max size
if tabHistory.count > maxHistorySize {
tabHistory.removeFirst(tabHistory.count - maxHistorySize)
}
historyIndex = tabHistory.count - 1
}
func navigateBack() {
guard historyIndex > 0 else { return }
// Find the previous valid tab in history (skip closed tabs)
var targetIndex = historyIndex - 1
while targetIndex >= 0 {
let tabId = tabHistory[targetIndex]
if tabs.contains(where: { $0.id == tabId }) {
isNavigatingHistory = true
historyIndex = targetIndex
selectedTabId = tabId
isNavigatingHistory = false
return
}
// Remove closed tab from history
tabHistory.remove(at: targetIndex)
historyIndex -= 1
targetIndex -= 1
}
}
func navigateForward() {
guard historyIndex < tabHistory.count - 1 else { return }
// Find the next valid tab in history (skip closed tabs)
let targetIndex = historyIndex + 1
while targetIndex < tabHistory.count {
let tabId = tabHistory[targetIndex]
if tabs.contains(where: { $0.id == tabId }) {
isNavigatingHistory = true
historyIndex = targetIndex
selectedTabId = tabId
isNavigatingHistory = false
return
}
// Remove closed tab from history
tabHistory.remove(at: targetIndex)
// Don't increment targetIndex since we removed the element
}
}
var canNavigateBack: Bool {
historyIndex > 0 && tabHistory.prefix(historyIndex).contains { tabId in
tabs.contains { $0.id == tabId }
}
}
var canNavigateForward: Bool {
historyIndex < tabHistory.count - 1 && tabHistory.suffix(from: historyIndex + 1).contains { tabId in
tabs.contains { $0.id == tabId }
}
}
// 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) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newTerminalSplit(
from: surfaceId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
focus: focus
)?.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 }
tab.moveFocus(direction: direction)
return true
}
/// Resize split - not directly supported by bonsplit, but we can adjust divider positions
func resizeSplit(tabId: UUID, surfaceId: UUID, direction: ResizeDirection, amount: UInt16) -> Bool {
// Bonsplit handles resize through its own divider dragging
// This is a no-op for now as bonsplit manages divider positions internally
return false
}
/// Equalize splits - not directly supported by bonsplit
func equalizeSplits(tabId: UUID) -> Bool {
// Bonsplit doesn't have a built-in equalize feature
// This would require manually setting all divider positions to 0.5
return false
}
/// Toggle zoom on a panel - bonsplit doesn't have zoom support
func toggleSplitZoom(tabId: UUID, surfaceId: UUID) -> Bool {
// Bonsplit doesn't have zoom support
return false
}
/// 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
}
// MARK: - Browser Panel Operations
/// Create a new browser panel in a split
func newBrowserSplit(
tabId: UUID,
fromPanelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil,
focus: Bool = true
) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newBrowserSplit(
from: fromPanelId,
orientation: orientation,
insertFirst: insertFirst,
url: url,
focus: focus
)?.id
}
/// Create a new browser surface in a pane
func newBrowserSurface(tabId: UUID, inPane paneId: PaneID, url: URL? = nil) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newBrowserSurface(inPane: paneId, url: url)?.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 the currently focused pane (as a new surface)
@discardableResult
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
guard let tabId = selectedTabId,
let tab = tabs.first(where: { $0.id == tabId }),
let focusedPaneId = tab.bonsplitController.focusedPaneId else { return nil }
let panel = tab.newBrowserSurface(
inPane: focusedPaneId,
url: url,
focus: true,
insertAtEnd: insertAtEnd
)
return panel?.id
}
/// Reopen the most recently closed browser panel (Cmd+Shift+T).
/// No-op when no browser panel restore snapshot is available.
@discardableResult
func reopenMostRecentlyClosedBrowserPanel() -> Bool {
while let snapshot = recentlyClosedBrowsers.pop() {
guard let targetWorkspace =
tabs.first(where: { $0.id == snapshot.workspaceId })
?? selectedWorkspace
?? tabs.first else {
return false
}
let preReopenFocusedPanelId = focusedPanelId(for: targetWorkspace.id)
if selectedTabId != targetWorkspace.id {
selectedTabId = targetWorkspace.id
}
if let reopenedPanelId = reopenClosedBrowserPanel(snapshot, in: targetWorkspace) {
enforceReopenedBrowserFocus(
tabId: targetWorkspace.id,
reopenedPanelId: reopenedPanelId,
preReopenFocusedPanelId: preReopenFocusedPanelId
)
return true
}
}
return false
}
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) {
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.newBrowserSplit(
from: anchorPanelId,
orientation: orientation,
insertFirst: snapshot.fallbackSplitInsertFirst,
url: snapshot.url
)?.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)?.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.triggerFocusFlash(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
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
}
var readyTerminal: TerminalPanel?
for _ in 0..<20 {
if let terminal = tab.focusedTerminalPanel,
terminal.hostedView.window != nil,
terminal.surface.surface != nil {
readyTerminal = terminal
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
guard let terminal = readyTerminal else {
let maybeTerminal = tab.focusedTerminalPanel
self.writeSplitCloseRightTestData([
"preTerminalAttached": (maybeTerminal?.hostedView.window != nil) ? "1" : "0",
"preTerminalSurfaceNil": (maybeTerminal?.surface.surface == nil) ? "1" : "0",
"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)
guard let topLeftPanelId = tab.focusedPanelId else {
self.writeSplitCloseRightTestData(["setupError": "Missing initial focused panel"], at: path)
return
}
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 Cmd+W.
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.hostedView.window != nil {
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.surface.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<Void, Never>) in
st.continuation = cont
var link: CVDisplayLink?
CVDisplayLinkCreateWithActiveCGDisplays(&link)
guard let link else {
st.finish()
Unmanaged<VsyncIOSurfaceTimelineState>.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)
}
try? await Task.sleep(nanoseconds: 80_000_000)
}
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.
let readyDeadline = Date().addingTimeInterval(2.0)
while Date() < readyDeadline {
let attached = rightPanel.hostedView.window != nil
let hasSurface = rightPanel.surface.surface != nil
if attached && hasSurface { break }
try? await Task.sleep(nanoseconds: 50_000_000)
}
// 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.surface.sendText("exit\r")
// Wait for the right panel to close.
let closed = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) 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.$panels
.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 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 closeDeadline = Date().addingTimeInterval(2.0)
while Date() < closeDeadline {
if tab.panels.count == 2 { break }
try? await Task.sleep(nanoseconds: 50_000_000)
}
if tab.panels.count != 2 {
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<UUID> = [leftPanelId, topRight.id]
for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) {
tab.focusPanel(panelId)
tab.closePanel(panelId, force: true)
let deadline = Date().addingTimeInterval(1.0)
while Date() < deadline {
if tab.panels[panelId] == nil { break }
try? await Task.sleep(nanoseconds: 25_000_000)
}
if tab.panels[panelId] != nil {
write([
"setupError": "Failed to close bottom pane \(panelId.uuidString)",
"done": "1",
])
return
}
}
exitPanelId = leftPanelId
let closeDeadline = Date().addingTimeInterval(2.0)
while Date() < closeDeadline {
if tab.panels.count == 2 { break }
try? await Task.sleep(nanoseconds: 50_000_000)
}
if tab.panels.count != 2 {
write([
"setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)",
"done": "1",
])
return
}
}
tab.focusPanel(exitPanelId)
try? await Task.sleep(nanoseconds: 100_000_000)
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,
"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.$panels
.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)
$tabs
.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
}
// Wait for the target panel to be fully attached after split churn.
let readyDeadline = Date().addingTimeInterval(2.0)
var attachedBeforeTrigger = false
var hasSurfaceBeforeTrigger = false
while Date() < readyDeadline {
guard let panel = tab.terminalPanel(for: exitPanelId) else {
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
return
}
attachedBeforeTrigger = panel.hostedView.window != nil
hasSurfaceBeforeTrigger = panel.surface.surface != nil
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
write([
"exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0",
"exitPanelHasSurfaceBeforeTrigger": hasSurfaceBeforeTrigger ? "1" : "0",
])
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() {
write(["autoTriggerSentCtrlDKey1": "1"])
} else {
write([
"autoTriggerCtrlDKeyUnavailable": "1",
"autoTriggerError": "ctrlDKeyUnavailable",
])
return
}
// In strict mode, never mask routing bugs with fallback writes.
if strictKeyOnly {
write(["autoTriggerMode": "strict_ctrl_d"])
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() {
write(["autoTriggerSentCtrlDKey2": "1"])
}
}
}
}
}
#endif
}
extension TabManager {
func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
let workspaceSnapshots = tabs
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in
tabs.firstIndex(where: { $0.id == selectedTabId })
}
return SessionTabManagerSnapshot(
selectedWorkspaceIndex: selectedWorkspaceIndex,
workspaces: workspaceSnapshots
)
}
func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) {
for tab in tabs {
unwireClosedBrowserTracking(for: tab)
}
tabs.removeAll(keepingCapacity: false)
lastFocusedPanelByTab.removeAll()
pendingPanelTitleUpdates.removeAll()
tabHistory.removeAll()
historyIndex = -1
isNavigatingHistory = false
pendingWorkspaceUnfocusTarget = nil
workspaceCycleCooldownTask?.cancel()
workspaceCycleCooldownTask = nil
isWorkspaceCycleHot = false
selectionSideEffectsGeneration &+= 1
recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
let workspaceSnapshots = snapshot.workspaces
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
for workspaceSnapshot in workspaceSnapshots {
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let workspace = Workspace(
title: workspaceSnapshot.processTitle,
workingDirectory: workspaceSnapshot.currentDirectory,
portOrdinal: ordinal
)
workspace.restoreSessionSnapshot(workspaceSnapshot)
wireClosedBrowserTracking(for: workspace)
tabs.append(workspace)
}
if tabs.isEmpty {
_ = addWorkspace(select: false)
}
selectedTabId = nil
if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex,
tabs.indices.contains(selectedWorkspaceIndex) {
selectedTabId = tabs[selectedWorkspaceIndex].id
} else {
selectedTabId = tabs.first?.id
}
if let selectedTabId {
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: selectedTabId]
)
}
}
}
// MARK: - Direction Types for Backwards Compatibility
/// Split direction for backwards compatibility with old API
enum SplitDirection {
case left, right, up, down
var isHorizontal: Bool {
self == .left || self == .right
}
var orientation: SplitOrientation {
isHorizontal ? .horizontal : .vertical
}
/// If true, insert the new pane on the "first" side (left/top).
/// If false, insert on the "second" side (right/bottom).
var insertFirst: Bool {
self == .left || self == .up
}
}
/// Resize direction for backwards compatibility
enum ResizeDirection {
case left, right, up, down
}
extension Notification.Name {
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface")
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick")
static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink")
}