cmux/Sources/TabManager.swift
Eray Bozoglu 2712cabac9
Fix orphaned child processes when closing workspace tabs (#889)
* Fix orphaned child processes when closing workspace tabs

When closing a workspace tab via the sidebar X button, child processes
(login → zsh → claude) survived as orphans because TabManager.closeWorkspace()
only removed the workspace from the tabs array without explicitly freeing
Ghostty surfaces. It relied on ARC to cascade deallocation, but SwiftUI views
and Combine publishers held references, delaying or preventing
ghostty_surface_free() (which sends SIGHUP) from ever running.

This adds explicit teardown on the workspace close path:
- TerminalSurface.teardownSurface(): idempotent method to free the Ghostty
  runtime surface eagerly, matching the existing deinit logic
- TerminalPanel.close() now calls teardownSurface() to ensure SIGHUP is sent
- Workspace.teardownAllPanels() iterates all panels and closes them
- TabManager.closeWorkspace() calls teardownAllPanels() before removing
  the workspace from the tabs array

* Harden workspace teardown and ownership checks

* Address follow-up teardown review feedback

---------

Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
2026-03-04 20:00:35 -08:00

3692 lines
153 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 String(localized: "workspace.placement.top", defaultValue: "Top")
case .afterCurrent:
return String(localized: "workspace.placement.afterCurrent", defaultValue: "After current")
case .end:
return String(localized: "workspace.placement.end", defaultValue: "End")
}
}
var description: String {
switch self {
case .top:
return String(localized: "workspace.placement.top.description", defaultValue: "Insert new workspaces at the top of the list.")
case .afterCurrent:
return String(localized: "workspace.placement.afterCurrent.description", defaultValue: "Insert new workspaces directly after the active workspace.")
case .end:
return String(localized: "workspace.placement.end.description", defaultValue: "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 SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable {
case leftRail
case solidFill
var id: String { rawValue }
var displayName: String {
switch self {
case .leftRail:
return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail")
case .solidFill:
return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill")
}
}
}
enum SidebarActiveTabIndicatorSettings {
static let styleKey = "sidebarActiveTabIndicatorStyle"
static let defaultStyle: SidebarActiveTabIndicatorStyle = .leftRail
static func resolvedStyle(rawValue: String?) -> SidebarActiveTabIndicatorStyle {
guard let rawValue else { return defaultStyle }
if let style = SidebarActiveTabIndicatorStyle(rawValue: rawValue) {
return style
}
// Legacy values from earlier iterations map to the closest modern option.
switch rawValue {
case "rail":
return .leftRail
case "border", "wash", "lift", "typography", "washRail", "blueWashColorRail":
return .solidFill
default:
return defaultStyle
}
}
static func current(defaults: UserDefaults = .standard) -> SidebarActiveTabIndicatorStyle {
resolvedStyle(rawValue: defaults.string(forKey: styleKey))
}
}
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)
}
}
}
struct WorkspaceTabColorEntry: Equatable, Identifiable {
let name: String
let hex: String
var id: String { "\(name)-\(hex)" }
}
enum WorkspaceTabColorSettings {
static let defaultOverridesKey = "workspaceTabColor.defaultOverrides"
static let customColorsKey = "workspaceTabColor.customColors"
static let maxCustomColors = 24
private static let originalPRPalette: [WorkspaceTabColorEntry] = [
WorkspaceTabColorEntry(name: "Red", hex: "#C0392B"),
WorkspaceTabColorEntry(name: "Crimson", hex: "#922B21"),
WorkspaceTabColorEntry(name: "Orange", hex: "#A04000"),
WorkspaceTabColorEntry(name: "Amber", hex: "#7D6608"),
WorkspaceTabColorEntry(name: "Olive", hex: "#4A5C18"),
WorkspaceTabColorEntry(name: "Green", hex: "#196F3D"),
WorkspaceTabColorEntry(name: "Teal", hex: "#006B6B"),
WorkspaceTabColorEntry(name: "Aqua", hex: "#0E6B8C"),
WorkspaceTabColorEntry(name: "Blue", hex: "#1565C0"),
WorkspaceTabColorEntry(name: "Navy", hex: "#1A5276"),
WorkspaceTabColorEntry(name: "Indigo", hex: "#283593"),
WorkspaceTabColorEntry(name: "Purple", hex: "#6A1B9A"),
WorkspaceTabColorEntry(name: "Magenta", hex: "#AD1457"),
WorkspaceTabColorEntry(name: "Rose", hex: "#880E4F"),
WorkspaceTabColorEntry(name: "Brown", hex: "#7B3F00"),
WorkspaceTabColorEntry(name: "Charcoal", hex: "#3E4B5E"),
]
static var defaultPalette: [WorkspaceTabColorEntry] {
originalPRPalette
}
static func palette(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] {
defaultPaletteWithOverrides(defaults: defaults) + customColorEntries(defaults: defaults)
}
static func defaultPaletteWithOverrides(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] {
let palette = defaultPalette
let overrides = defaultOverrideMap(defaults: defaults)
return palette.map { entry in
WorkspaceTabColorEntry(name: entry.name, hex: overrides[entry.name] ?? entry.hex)
}
}
static func defaultColorHex(named name: String, defaults: UserDefaults = .standard) -> String {
let palette = defaultPalette
guard let entry = palette.first(where: { $0.name == name }) else {
return palette.first?.hex ?? "#1565C0"
}
return defaultOverrideMap(defaults: defaults)[name] ?? entry.hex
}
static func setDefaultColor(named name: String, hex: String, defaults: UserDefaults = .standard) {
let palette = defaultPalette
guard let entry = palette.first(where: { $0.name == name }),
let normalized = normalizedHex(hex) else { return }
var overrides = defaultOverrideMap(defaults: defaults)
if normalized == entry.hex {
overrides.removeValue(forKey: name)
} else {
overrides[name] = normalized
}
saveDefaultOverrideMap(overrides, defaults: defaults)
}
static func customColors(defaults: UserDefaults = .standard) -> [String] {
guard let raw = defaults.array(forKey: customColorsKey) as? [String] else { return [] }
var result: [String] = []
var seen: Set<String> = []
for value in raw {
guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue }
result.append(normalized)
if result.count >= maxCustomColors { break }
}
return result
}
static func customColorEntries(defaults: UserDefaults = .standard) -> [WorkspaceTabColorEntry] {
customColors(defaults: defaults).enumerated().map { index, hex in
WorkspaceTabColorEntry(name: "Custom \(index + 1)", hex: hex)
}
}
@discardableResult
static func addCustomColor(_ hex: String, defaults: UserDefaults = .standard) -> String? {
guard let normalized = normalizedHex(hex) else { return nil }
var colors = customColors(defaults: defaults)
colors.removeAll { $0 == normalized }
colors.insert(normalized, at: 0)
setCustomColors(colors, defaults: defaults)
return normalized
}
static func removeCustomColor(_ hex: String, defaults: UserDefaults = .standard) {
guard let normalized = normalizedHex(hex) else { return }
var colors = customColors(defaults: defaults)
colors.removeAll { $0 == normalized }
setCustomColors(colors, defaults: defaults)
}
static func setCustomColors(_ hexes: [String], defaults: UserDefaults = .standard) {
var normalizedColors: [String] = []
var seen: Set<String> = []
for value in hexes {
guard let normalized = normalizedHex(value), seen.insert(normalized).inserted else { continue }
normalizedColors.append(normalized)
if normalizedColors.count >= maxCustomColors { break }
}
if normalizedColors.isEmpty {
defaults.removeObject(forKey: customColorsKey)
} else {
defaults.set(normalizedColors, forKey: customColorsKey)
}
}
static func reset(defaults: UserDefaults = .standard) {
defaults.removeObject(forKey: defaultOverridesKey)
defaults.removeObject(forKey: customColorsKey)
}
static func normalizedHex(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let body = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
guard body.count == 6 else { return nil }
guard UInt64(body, radix: 16) != nil else { return nil }
return "#" + body.uppercased()
}
static func displayColor(
hex: String,
colorScheme: ColorScheme,
forceBright: Bool = false
) -> Color? {
guard let color = displayNSColor(hex: hex, colorScheme: colorScheme, forceBright: forceBright) else {
return nil
}
return Color(nsColor: color)
}
static func displayNSColor(
hex: String,
colorScheme: ColorScheme,
forceBright: Bool = false
) -> NSColor? {
guard let normalized = normalizedHex(hex),
let baseColor = NSColor(hex: normalized) else {
return nil
}
if forceBright || colorScheme == .dark {
return brightenedForDarkAppearance(baseColor)
}
return baseColor
}
private static func defaultOverrideMap(defaults: UserDefaults) -> [String: String] {
guard let raw = defaults.dictionary(forKey: defaultOverridesKey) as? [String: String] else { return [:] }
let validNames = Set(defaultPalette.map(\.name))
var normalized: [String: String] = [:]
for (name, hex) in raw {
guard validNames.contains(name),
let normalizedHex = normalizedHex(hex) else { continue }
normalized[name] = normalizedHex
}
return normalized
}
private static func saveDefaultOverrideMap(_ map: [String: String], defaults: UserDefaults) {
if map.isEmpty {
defaults.removeObject(forKey: defaultOverridesKey)
} else {
defaults.set(map, forKey: defaultOverridesKey)
}
}
private static func brightenedForDarkAppearance(_ color: NSColor) -> NSColor {
let rgbColor = color.usingColorSpace(.sRGB) ?? color
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
rgbColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
let boostedBrightness = min(1, max(brightness, 0.62) + ((1 - brightness) * 0.28))
// Preserve neutral grays when brightening to avoid introducing hue shifts.
let boostedSaturation: CGFloat
if saturation <= 0.08 {
boostedSaturation = saturation
} else {
boostedSaturation = min(1, saturation + ((1 - saturation) * 0.12))
}
return NSColor(
hue: hue,
saturation: boostedSaturation,
brightness: boostedBrightness,
alpha: alpha
)
}
}
/// 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 {
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
weak var window: NSWindow?
@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 }
sentryBreadcrumb("workspace.switch", data: [
"tabCount": tabs.count
])
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 {
if selectedTerminalPanel?.searchState != nil { return true }
if focusedBrowserPanel?.searchState != nil { return true }
return false
}
var canUseSelectionForFind: Bool {
if focusedBrowserPanel != nil { return false }
return selectedTerminalPanel?.hasSelection() == true
}
func startSearch() {
if let browser = focusedBrowserPanel {
browser.startFind()
return
}
guard let panel = selectedTerminalPanel else {
#if DEBUG
dlog("find.startSearch SKIPPED no selectedTerminalPanel")
#endif
return
}
let wasNil = panel.searchState == nil
if wasNil {
panel.searchState = TerminalSurface.SearchState()
}
#if DEBUG
dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))")
#endif
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()
}
#if DEBUG
dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))")
#endif
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("search_selection")
}
func findNext() {
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.findNext()
return
}
_ = selectedTerminalPanel?.performBindingAction("search:next")
}
func findPrevious() {
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.findPrevious()
return
}
_ = selectedTerminalPanel?.performBindingAction("search:previous")
}
@discardableResult
func toggleFocusedTerminalCopyMode() -> Bool {
guard let panel = selectedTerminalPanel else { return false }
return panel.surface.toggleKeyboardCopyMode()
}
func hideFind() {
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.hideFind()
return
}
#if DEBUG
dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")")
#endif
selectedTerminalPanel?.searchState = nil
}
@discardableResult
func addWorkspace(
workingDirectory overrideWorkingDirectory: String? = nil,
select: Bool = true,
placementOverride: NewWorkspacePlacement? = nil
) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let newWorkspace = Workspace(
title: "Terminal \(tabs.count + 1)",
workingDirectory: workingDirectory,
portOrdinal: ordinal,
configTemplate: inheritedConfig
)
wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex(placementOverride: placementOverride)
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) }
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
guard let workspace = selectedWorkspace else { return nil }
if let focusedTerminal = workspace.focusedTerminalPanel {
return focusedTerminal
}
if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() {
return rememberedTerminal
}
if let focusedPaneId = workspace.bonsplitController.focusedPaneId,
let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) {
return paneTerminal
}
return workspace.terminalPanelForConfigInheritance()
}
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
return cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_TAB
)
}
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints
return config
}
return nil
}
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(placementOverride: NewWorkspacePlacement? = nil) -> Int {
let placement = placementOverride ?? 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 setTabColor(tabId: UUID, color: String?) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
tab.setCustomColor(color)
}
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 }
guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
unwireClosedBrowserTracking(for: workspace)
workspace.teardownAllPanels()
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 canCloseOtherTabsInFocusedPane() -> Bool {
closeOtherTabsInFocusedPanePlan() != nil
}
func closeOtherTabsInFocusedPaneWithConfirmation() {
guard let plan = closeOtherTabsInFocusedPanePlan() else { return }
let count = plan.panelIds.count
let titleLines = plan.titles.map { "\($0)" }.joined(separator: "\n")
let message = if count == 1 {
String(localized: "dialog.closeOtherTabs.message.one", defaultValue: "This will close 1 tab in this pane:\n\(titleLines)")
} else {
String(localized: "dialog.closeOtherTabs.message.other", defaultValue: "This will close \(count) tabs in this pane:\n\(titleLines)")
}
guard confirmClose(
title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"),
message: message,
acceptCmdD: false
) else { return }
for panelId in plan.panelIds {
_ = plan.workspace.closePanel(panelId, force: true)
}
}
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: String(localized: "common.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "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 struct CloseOtherTabsInFocusedPanePlan {
let workspace: Workspace
let panelIds: [UUID]
let titles: [String]
}
private func closeOtherTabsInFocusedPanePlan() -> CloseOtherTabsInFocusedPanePlan? {
guard let workspace = selectedWorkspace else { return nil }
guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else {
return nil
}
let tabsInPane = workspace.bonsplitController.tabs(inPane: paneId)
guard !tabsInPane.isEmpty else { return nil }
guard let selectedTabId = workspace.bonsplitController.selectedTab(inPane: paneId)?.id ?? tabsInPane.first?.id else {
return nil
}
var targetPanelIds: [UUID] = []
var targetTitles: [String] = []
for tab in tabsInPane where tab.id != selectedTabId {
guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue }
if workspace.isPanelPinned(panelId) {
continue
}
targetPanelIds.append(panelId)
targetTitles.append(closeOtherTabsDisplayTitle(workspace.panelTitle(panelId: panelId)))
}
guard !targetPanelIds.isEmpty else { return nil }
return CloseOtherTabsInFocusedPanePlan(
workspace: workspace,
panelIds: targetPanelIds,
titles: targetTitles
)
}
private func closeOtherTabsDisplayTitle(_ title: String?) -> String {
let collapsed = title?
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
if let collapsed, !collapsed.isEmpty {
return collapsed
}
return String(localized: "tab.untitled", defaultValue: "Untitled Tab")
}
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) {
let willCloseWindow = tabs.count <= 1
if workspaceNeedsConfirmClose(workspace),
!confirmClose(
title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"),
message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."),
acceptCmdD: willCloseWindow
) {
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) {
let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in
partial + tab.bonsplitController.tabs(inPane: paneId).count
}
let panelKind: String = {
guard let panel = tab.panels[panelId] else { return "missing" }
if panel is TerminalPanel { return "terminal" }
if panel is BrowserPanel { return "browser" }
return String(describing: type(of: panel))
}()
#if DEBUG
dlog(
"surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " +
"panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount)"
)
#endif
// 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 effectiveSurfaceCount = max(tab.panels.count, bonsplitTabCount)
let isLastTabInWorkspace = effectiveSurfaceCount <= 1
if isLastTabInWorkspace {
let willCloseWindow = tabs.count <= 1
let needsConfirm = workspaceNeedsConfirmClose(tab)
if needsConfirm {
let message = willCloseWindow
? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.")
: String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.")
#if DEBUG
dlog(
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=lastTab"
)
#endif
guard confirmClose(
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: message,
acceptCmdD: willCloseWindow
) else {
#if DEBUG
dlog(
"surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=lastTabConfirmDismissed"
)
#endif
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() {
#if DEBUG
dlog(
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=terminalNeedsConfirm"
)
#endif
guard confirmClose(
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
acceptCmdD: false
) else {
#if DEBUG
dlog(
"surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=terminalConfirmDismissed"
)
#endif
return
}
}
// We already confirmed (if needed); bypass Bonsplit's delegate gating.
let closed = tab.closePanel(panelId, force: true)
#if DEBUG
dlog(
"surface.close.shortcut tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) " +
"panelsAfterCall=\(tab.panels.count)"
)
#endif
}
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: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "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 }
#if DEBUG
dlog(
"surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)"
)
#endif
// Keep AppKit first responder in sync with workspace focus before routing the close.
// If split reparenting caused a temporary model/view mismatch, fallback close logic in
// Workspace.closePanel uses focused selection to resolve the correct tab deterministically.
reconcileFocusedPanelFromFirstResponderForKeyboard()
let closed = tab.closePanel(surfaceId, force: true)
#if DEBUG
dlog(
"surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)"
)
#endif
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId)
}
/// 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 }
#if DEBUG
dlog(
"surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count)"
)
#endif
// 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)
guard let targetWindow = window else { return }
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 let tab = tabs.first(where: { $0.id == tabId }) else { return }
if let surfaceId, tab.panels[surfaceId] != nil {
// Keep selected-surface intent stable across selectedTabId didSet async restore.
lastFocusedPanelByTab[tabId] = surfaceId
}
selectedTabId = tabId
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: tabId]
)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
NSApp.activate(ignoringOtherApps: true)
NSApp.unhide(nil)
if let app = AppDelegate.shared,
let windowId = app.windowId(for: self),
let window = app.mainWindow(for: windowId) {
window.makeKeyAndOrderFront(nil)
} else if let window = NSApp.keyWindow ?? NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
}
}
if let surfaceId {
if !suppressFlash {
focusSurface(tabId: tabId, surfaceId: surfaceId)
} else {
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?.clearSplitZoom()
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 }
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.create.request kind=terminal dir=\(directionLabel) " +
"tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
tab.clearSplitZoom()
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
let createdPanelId = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
#if DEBUG
dlog(
"split.create.result kind=terminal dir=\(directionLabel) " +
"created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
}
/// 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 }
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.create.request kind=browser dir=\(directionLabel) " +
"tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
tab.clearSplitZoom()
let createdPanelId = newBrowserSplit(
tabId: selectedTabId,
fromPanelId: focusedPanelId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
url: url
)
#if DEBUG
dlog(
"split.create.result kind=browser dir=\(directionLabel) " +
"created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
return createdPanelId
}
/// 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 }
let createdPanel = tab.newTerminalSplit(
from: surfaceId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
focus: focus
)?.id
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.newSurface result dir=\(directionLabel) " +
"tab=\(tabId.uuidString.prefix(5)) source=\(surfaceId.uuidString.prefix(5)) " +
"created=\(createdPanel?.uuidString.prefix(5) ?? "nil") focus=\(focus ? 1 : 0)"
)
#endif
return createdPanel
}
/// 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 {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return false }
var foundSplit = false
var allSucceeded = true
equalizeSplits(
in: tab.bonsplitController.treeSnapshot(),
controller: tab.bonsplitController,
foundSplit: &foundSplit,
allSucceeded: &allSucceeded
)
return foundSplit && allSucceeded
}
/// Toggle zoom on a panel.
func toggleSplitZoom(tabId: UUID, surfaceId: UUID) -> Bool {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return false }
return tab.toggleSplitZoom(panelId: surfaceId)
}
/// Toggle zoom for the currently focused panel in the selected workspace.
@discardableResult
func toggleFocusedSplitZoom() -> Bool {
guard let tab = selectedWorkspace,
let focusedPanelId = tab.focusedPanelId else { return false }
return tab.toggleSplitZoom(panelId: focusedPanelId)
}
private func equalizeSplits(
in node: ExternalTreeNode,
controller: BonsplitController,
foundSplit: inout Bool,
allSucceeded: inout Bool
) {
switch node {
case .pane:
return
case .split(let splitNode):
foundSplit = true
guard let splitId = UUID(uuidString: splitNode.id) else {
allSucceeded = false
return
}
if !controller.setDividerPosition(0.5, forSplit: splitId) {
allSucceeded = false
}
equalizeSplits(
in: splitNode.first,
controller: controller,
foundSplit: &foundSplit,
allSucceeded: &allSucceeded
)
equalizeSplits(
in: splitNode.second,
controller: controller,
foundSplit: &foundSplit,
allSucceeded: &allSucceeded
)
}
}
/// 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 a specific workspace, optionally preferring a split-right layout.
@discardableResult
func openBrowser(
inWorkspace tabId: UUID,
url: URL? = nil,
preferSplitRight: Bool = false,
insertAtEnd: Bool = false
) -> UUID? {
guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil }
if selectedTabId != tabId {
selectedTabId = tabId
}
if preferSplitRight {
if let targetPaneId = workspace.topRightBrowserReusePane(),
let browserPanel = workspace.newBrowserSurface(
inPane: targetPaneId,
url: url,
focus: true,
insertAtEnd: insertAtEnd
) {
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
return browserPanel.id
}
let splitSourcePanelId: UUID? = {
if let focusedPanelId = workspace.focusedPanelId,
workspace.panels[focusedPanelId] != nil {
return focusedPanelId
}
if let rememberedPanelId = lastFocusedPanelByTab[tabId],
workspace.panels[rememberedPanelId] != nil {
return rememberedPanelId
}
if let orderedPanelId = workspace.sidebarOrderedPanelIds().first(where: { workspace.panels[$0] != nil }) {
return orderedPanelId
}
return workspace.panels.keys.sorted { $0.uuidString < $1.uuidString }.first
}()
if let splitSourcePanelId,
let browserPanel = workspace.newBrowserSplit(
from: splitSourcePanelId,
orientation: .horizontal,
url: url,
focus: true
) {
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
return browserPanel.id
}
}
guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first,
let browserPanel = workspace.newBrowserSurface(
inPane: paneId,
url: url,
focus: true,
insertAtEnd: insertAtEnd
) else {
return nil
}
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
return browserPanel.id
}
/// Open a browser in the currently focused pane (as a new surface)
@discardableResult
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
guard let tabId = selectedTabId else { return nil }
return openBrowser(
inWorkspace: tabId,
url: url,
preferSplitRight: false,
insertAtEnd: insertAtEnd
)
}
/// 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
@MainActor
private func waitForTerminalPanelReadyForUITest(
tab: Workspace,
panelId: UUID,
timeoutSeconds: TimeInterval = 6.0
) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) {
let deadline = Date().addingTimeInterval(timeoutSeconds)
var attached = false
var hasSurface = false
var firstResponder = false
while Date() < deadline {
guard let panel = tab.terminalPanel(for: panelId) else {
return (false, false, false)
}
panel.surface.requestBackgroundSurfaceStartIfNeeded()
attached = panel.hostedView.window != nil
hasSurface = panel.surface.surface != nil
firstResponder = panel.hostedView.isSurfaceViewFirstResponder()
if attached, hasSurface {
return (attached, hasSurface, firstResponder)
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
return (attached, hasSurface, firstResponder)
}
private func setupUITestFocusShortcutsIfNeeded() {
guard !didSetupUITestFocusShortcuts else { return }
didSetupUITestFocusShortcuts = true
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_FOCUS_SHORTCUTS"] == "1" else { return }
// UI tests can't record arrow keys via the shortcut recorder. Use letter-based shortcuts
// so tests can reliably drive pane navigation without mouse clicks.
KeyboardShortcutSettings.setShortcut(
StoredShortcut(key: "h", command: true, shift: false, option: false, control: true),
for: .focusLeft
)
KeyboardShortcutSettings.setShortcut(
StoredShortcut(key: "l", command: true, shift: false, option: false, control: true),
for: .focusRight
)
KeyboardShortcutSettings.setShortcut(
StoredShortcut(key: "k", command: true, shift: false, option: false, control: true),
for: .focusUp
)
KeyboardShortcutSettings.setShortcut(
StoredShortcut(key: "j", command: true, shift: false, option: false, control: true),
for: .focusDown
)
}
private func setupSplitCloseRightUITestIfNeeded() {
guard !didSetupSplitCloseRightUITest else { return }
didSetupSplitCloseRightUITest = true
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] == "1" else { return }
guard let path = env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"], !path.isEmpty else { return }
let visualMode = env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] == "1"
let shotsDir = (env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SHOTS_DIR"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let visualIterations = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] ?? "20").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 20
let burstFrames = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] ?? "6").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 6
let closeDelayMs = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] ?? "70").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 70
let pattern = (env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] ?? "close_right")
.trimmingCharacters(in: .whitespacesAndNewlines)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
guard let self else { return }
Task { @MainActor [weak self] in
guard let self else { return }
guard let tab = self.selectedWorkspace else {
self.writeSplitCloseRightTestData(["setupError": "Missing selected workspace"], at: path)
return
}
guard let topLeftPanelId = tab.focusedPanelId else {
self.writeSplitCloseRightTestData(["setupError": "Missing initial focused panel"], at: path)
return
}
let initialTerminalReadiness = await self.waitForTerminalPanelReadyForUITest(
tab: tab,
panelId: topLeftPanelId
)
guard initialTerminalReadiness.attached,
initialTerminalReadiness.hasSurface,
let terminal = tab.terminalPanel(for: topLeftPanelId) else {
self.writeSplitCloseRightTestData([
"preTerminalAttached": initialTerminalReadiness.attached ? "1" : "0",
"preTerminalSurfaceNil": initialTerminalReadiness.hasSurface ? "0" : "1",
"setupError": "Initial terminal not ready (not attached or surface nil)"
], at: path)
return
}
self.writeSplitCloseRightTestData([
"preTerminalAttached": "1",
"preTerminalSurfaceNil": terminal.surface.surface == nil ? "1" : "0"
], at: path)
if visualMode {
// Visual repro mode: repeat the split/close sequence many times and write
// screenshots to `shotsDir`. This avoids relying on XCUITest to click hover-only
// close buttons, while still exercising the "close unfocused right tabs" path.
self.writeSplitCloseRightTestData([
"visualMode": "1",
"visualIterations": String(visualIterations),
"visualDone": "0"
], at: path)
await self.runSplitCloseRightVisualRepro(
tab: tab,
topLeftPanelId: topLeftPanelId,
path: path,
shotsDir: shotsDir,
iterations: max(1, min(visualIterations, 60)),
burstFrames: max(0, min(burstFrames, 80)),
closeDelayMs: max(0, min(closeDelayMs, 500)),
pattern: pattern
)
self.writeSplitCloseRightTestData(["visualDone": "1"], at: path)
return
}
// Layout goal: 2x2 grid (2 top, 2 bottom), then close both right panels.
// Order matters: split down first, then split right in each row (matches UI shortcut repro).
guard let bottomLeft = tab.newTerminalSplit(from: topLeftPanelId, orientation: .vertical) else {
self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-left split"], at: path)
return
}
guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else {
self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-right split"], at: path)
return
}
tab.focusPanel(topLeftPanelId)
guard let topRight = tab.newTerminalSplit(from: topLeftPanelId, orientation: .horizontal) else {
self.writeSplitCloseRightTestData(["setupError": "Failed to create top-right split"], at: path)
return
}
self.writeSplitCloseRightTestData([
"tabId": tab.id.uuidString,
"topLeftPanelId": topLeftPanelId.uuidString,
"bottomLeftPanelId": bottomLeft.id.uuidString,
"topRightPanelId": topRight.id.uuidString,
"bottomRightPanelId": bottomRight.id.uuidString,
"createdPaneCount": String(tab.bonsplitController.allPaneIds.count),
"createdPanelCount": String(tab.panels.count)
], at: path)
DebugUIEventCounters.resetEmptyPanelAppearCount()
// Close the two right panes via the same path as 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 useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d"
let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d"
let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger
let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger
let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr")
.trimmingCharacters(in: .whitespacesAndNewlines)
let expectedPanelsAfter = max(
1,
Int((env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] ?? "1")
.trimmingCharacters(in: .whitespacesAndNewlines)
) ?? 1
)
func write(_ updates: [String: String]) {
var payload: [String: String] = {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
return [:]
}
return obj
}()
for (k, v) in updates { payload[k] = v }
guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return }
try? out.write(to: URL(fileURLWithPath: path), options: .atomic)
}
Task { @MainActor [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: 200_000_000)
guard let tab = self.selectedWorkspace else {
write(["setupError": "Missing selected workspace", "done": "1"])
return
}
guard let leftPanelId = tab.focusedPanelId else {
write(["setupError": "Missing initial focused panel", "done": "1"])
return
}
guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
write(["setupError": "Failed to create right split", "done": "1"])
return
}
var bottomLeftPanelId = ""
let topRightPanelId = rightPanel.id.uuidString
var bottomRightPanelId = ""
var exitPanelId = rightPanel.id
if layout == "lr_left_vertical" {
guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else {
write(["setupError": "Failed to create bottom-left split", "done": "1"])
return
}
bottomLeftPanelId = bottomLeft.id.uuidString
} else if layout == "lrtd_close_right_then_exit_top_left" {
guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else {
write(["setupError": "Failed to create bottom-left split", "done": "1"])
return
}
guard let bottomRight = tab.newTerminalSplit(from: rightPanel.id, orientation: .vertical) else {
write(["setupError": "Failed to create bottom-right split", "done": "1"])
return
}
bottomLeftPanelId = bottomLeft.id.uuidString
bottomRightPanelId = bottomRight.id.uuidString
// Repro flow: with a 2x2 (left/right then top/down), close both right panes,
// then trigger Ctrl+D in top-left.
tab.focusPanel(rightPanel.id)
tab.closePanel(rightPanel.id, force: true)
tab.focusPanel(bottomRight.id)
tab.closePanel(bottomRight.id, force: true)
exitPanelId = leftPanelId
let 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)
// Keep child-exit keyboard tests deterministic across user shell configs.
// `exec cat` exits on a single Ctrl+D and avoids ignore-eof shell settings.
if let exitPanel = tab.terminalPanel(for: exitPanelId) {
exitPanel.sendText("exec cat\r")
}
var exitPanelAttachedBeforeCtrlD = false
var exitPanelHasSurfaceBeforeCtrlD = false
if !useEarlyTrigger {
let readiness = await self.waitForTerminalPanelReadyForUITest(
tab: tab,
panelId: exitPanelId
)
exitPanelAttachedBeforeCtrlD = readiness.attached
exitPanelHasSurfaceBeforeCtrlD = readiness.hasSurface
if !(readiness.attached && readiness.hasSurface) {
write([
"exitPanelAttachedBeforeCtrlD": readiness.attached ? "1" : "0",
"exitPanelHasSurfaceBeforeCtrlD": readiness.hasSurface ? "1" : "0",
"setupError": "Exit panel not ready for Ctrl+D (not attached or surface nil)",
"done": "1",
])
return
}
self.ensureFocusedTerminalFirstResponder()
try? await Task.sleep(nanoseconds: 80_000_000)
} else if let exitPanel = tab.terminalPanel(for: exitPanelId) {
exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil
exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil
}
let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? ""
let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in
guard let terminal = panel as? TerminalPanel else { return nil }
return terminal.hostedView.isSurfaceViewFirstResponder() ? panelId : nil
}.first?.uuidString ?? ""
write([
"workspaceId": tab.id.uuidString,
"leftPanelId": leftPanelId.uuidString,
"rightPanelId": rightPanel.id.uuidString,
"topRightPanelId": topRightPanelId,
"bottomLeftPanelId": bottomLeftPanelId,
"bottomRightPanelId": bottomRightPanelId,
"exitPanelId": exitPanelId.uuidString,
"panelCountBeforeCtrlD": String(tab.panels.count),
"layout": layout,
"expectedPanelsAfter": String(expectedPanelsAfter),
"focusedPanelBefore": focusedPanelBefore,
"firstResponderPanelBefore": firstResponderPanelBefore,
"exitPanelAttachedBeforeCtrlD": exitPanelAttachedBeforeCtrlD ? "1" : "0",
"exitPanelHasSurfaceBeforeCtrlD": exitPanelHasSurfaceBeforeCtrlD ? "1" : "0",
"ready": "1",
"done": "0",
])
var finished = false
var timeoutWork: DispatchWorkItem?
@MainActor
func finish(_ updates: [String: String]) {
guard !finished else { return }
finished = true
timeoutWork?.cancel()
write(updates.merging(["done": "1"], uniquingKeysWith: { _, new in new }))
self.uiTestCancellables.removeAll()
}
tab.$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
}
let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift
? [.control, .shift]
: [.control]
let shouldWaitForSurface = !useEarlyTrigger
var attachedBeforeTrigger = false
var hasSurfaceBeforeTrigger = false
if shouldWaitForSurface {
// Wait for the target panel to be fully attached after split churn.
let readyDeadline = Date().addingTimeInterval(5.0)
while Date() < readyDeadline {
guard let panel = tab.terminalPanel(for: exitPanelId) else {
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
return
}
panel.surface.requestBackgroundSurfaceStartIfNeeded()
attachedBeforeTrigger = panel.hostedView.window != nil
hasSurfaceBeforeTrigger = panel.surface.surface != nil
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
}
} else if let panel = tab.terminalPanel(for: exitPanelId) {
attachedBeforeTrigger = panel.hostedView.window != nil
hasSurfaceBeforeTrigger = panel.surface.surface != nil
}
write([
"exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0",
"exitPanelHasSurfaceBeforeTrigger": hasSurfaceBeforeTrigger ? "1" : "0",
])
if shouldWaitForSurface && !(attachedBeforeTrigger && hasSurfaceBeforeTrigger) {
write(["autoTriggerError": "exitPanelNotReadyBeforeTrigger"])
return
}
guard let panel = tab.terminalPanel(for: exitPanelId) else {
write(["autoTriggerError": "missingExitPanelAtTrigger"])
return
}
// Exercise the real key path (ghostty_surface_key for Ctrl+D).
if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) {
write(["autoTriggerSentCtrlDKey1": "1"])
} else {
write([
"autoTriggerCtrlDKeyUnavailable": "1",
"autoTriggerError": "ctrlDKeyUnavailable",
])
return
}
// In strict mode, never mask routing bugs with fallback writes.
if strictKeyOnly {
let strictModeLabel: String = {
if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" }
if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" }
if triggerUsesShift { return "strict_ctrl_shift_d" }
return "strict_ctrl_d"
}()
write(["autoTriggerMode": strictModeLabel])
return
}
// Non-strict mode keeps one additional Ctrl+D retry for startup timing variance.
try? await Task.sleep(nanoseconds: 450_000_000)
if tab.panels[exitPanelId] != nil,
panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) {
write(["autoTriggerSentCtrlDKey2": "1"])
}
}
}
}
}
#endif
}
extension TabManager {
func sessionAutosaveFingerprint() -> Int {
var hasher = Hasher()
hasher.combine(selectedTabId)
hasher.combine(tabs.count)
for workspace in tabs.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) {
hasher.combine(workspace.id)
hasher.combine(workspace.focusedPanelId)
hasher.combine(workspace.currentDirectory)
hasher.combine(workspace.customTitle ?? "")
hasher.combine(workspace.customColor ?? "")
hasher.combine(workspace.isPinned)
hasher.combine(workspace.panels.count)
hasher.combine(workspace.statusEntries.count)
hasher.combine(workspace.metadataBlocks.count)
hasher.combine(workspace.logEntries.count)
hasher.combine(workspace.panelDirectories.count)
hasher.combine(workspace.panelTitles.count)
hasher.combine(workspace.panelPullRequests.count)
hasher.combine(workspace.panelGitBranches.count)
hasher.combine(workspace.surfaceListeningPorts.count)
if let progress = workspace.progress {
hasher.combine(Int((progress.value * 1000).rounded()))
hasher.combine(progress.label)
} else {
hasher.combine(-1)
}
if let gitBranch = workspace.gitBranch {
hasher.combine(gitBranch.branch)
hasher.combine(gitBranch.isDirty)
} else {
hasher.combine("")
hasher.combine(false)
}
}
return hasher.finalize()
}
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)
}
// Clear non-@Published state without touching tabs/selectedTabId yet.
lastFocusedPanelByTab.removeAll()
pendingPanelTitleUpdates.removeAll()
tabHistory.removeAll()
historyIndex = -1
isNavigatingHistory = false
pendingWorkspaceUnfocusTarget = nil
workspaceCycleCooldownTask?.cancel()
workspaceCycleCooldownTask = nil
isWorkspaceCycleHot = false
selectionSideEffectsGeneration &+= 1
recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
// Build the new workspace list locally to avoid intermediate @Published
// emissions (empty tabs, nil selectedTabId) that can leave SwiftUI's
// mountedWorkspaceIds empty and cause a frozen blank launch state (#399).
var newTabs: [Workspace] = []
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)
newTabs.append(workspace)
}
if newTabs.isEmpty {
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let fallback = Workspace(title: "Terminal 1", portOrdinal: ordinal)
wireClosedBrowserTracking(for: fallback)
newTabs.append(fallback)
}
// Determine selection before mutating @Published properties.
let newSelectedId: UUID?
if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex,
newTabs.indices.contains(selectedWorkspaceIndex) {
newSelectedId = newTabs[selectedWorkspaceIndex].id
} else {
newSelectedId = newTabs.first?.id
}
// Single atomic assignment of @Published properties so SwiftUI observers
// never see an intermediate state with empty tabs or nil selection.
tabs = newTabs
selectedTabId = newSelectedId
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
}
var debugLabel: String {
switch self {
case .left: return "left"
case .right: return "right"
case .up: return "up"
case .down: return "down"
}
}
}
/// Resize direction for backwards compatibility
enum ResizeDirection {
case left, right, up, down
}
extension Notification.Name {
static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested")
static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested")
static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested")
static let commandPaletteSubmitRequested = Notification.Name("cmux.commandPaletteSubmitRequested")
static let commandPaletteDismissRequested = Notification.Name("cmux.commandPaletteDismissRequested")
static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested")
static let commandPaletteRenameWorkspaceRequested = Notification.Name("cmux.commandPaletteRenameWorkspaceRequested")
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")
static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested")
static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested")
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 browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView")
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")
}