* Add React Grab inject button to browser toolbar Adds a toolbar button (cursor click icon) that injects the react-grab script (unpkg.com/react-grab/dist/index.global.js) into the current page. Hover over React elements and Cmd+C to copy component context (file, component name, line number) for AI agents. Button highlights when active, resets on navigation. * Auto-activate selection mode on React Grab inject First click: injects the script and auto-activates selection mode via the react-grab:init event. Subsequent clicks toggle selection mode on/off via window.__REACT_GRAB__.toggle(). * Bridge React Grab state back to Swift via WKScriptMessageHandler Register a cmux-bridge plugin after injecting react-grab that posts state changes back to Swift via webkit.messageHandlers. The button now highlights accent color only when selection mode is actually active (not just when the script is loaded), and deactivates when the user exits selection mode via Escape or the react-grab toolbar. * Fetch react-grab script via URLSession to bypass CSP Sites like vercel.com block loading external scripts via CSP headers. Fetch the script with URLSession (not subject to page CSP), cache it, and inject inline via evaluateJavaScript. Also guard against duplicate injection on repeated clicks. * Prefetch react-grab script on first browser panel init Kick off a low-priority background fetch of the react-grab script when the first BrowserPanel is created. The script is cached statically so clicking the button is instant. * Eliminate react-grab button and callback lag Three changes: 1. Fire-and-forget: use evaluateJavaScript with completionHandler instead of await, so button taps return immediately. 2. Single JS payload: combine bootstrap listener + script source into one evaluateJavaScript call (one IPC round-trip, not two). 3. Dedupe state callbacks: only post webkit message when isActive actually changes, not on every hover/drag state update. * Fix duplicate state callback on react-grab toggle toggleReactGrab was sending an explicit postMessage AND the plugin's onStateChange hook was firing too, causing two @Published updates per toggle. Remove the explicit postMessage since the plugin hook handles it. Also add dlog instrumentation for debugging. * Add Cmd+Shift+G shortcut for React Grab (configurable) - Add toggleReactGrab to KeyboardShortcutSettings with Cmd+Shift+G default - Add View menu item with customizable shortcut - Add command palette entry (searchable as "react grab" or "inspect element") - Simplify button to use toggleOrInjectReactGrab, remove local state tracking * Fix Codex review findings: pin version, verify hash, fix retry and state 1. Pin react-grab to exact version (0.1.29) with SHA-256 integrity check. Script is verified before evaluation to prevent supply-chain attacks via compromised CDN responses. 2. Clear prefetchTask on failure so subsequent attempts retry the download instead of reusing a permanently failed task. 3. Remove premature isReactGrabActive=true. State is now only set by the onStateChange message handler callback after confirmed initialization, or explicitly reset on evaluation error. * Extract React Grab into own file, make version configurable Move all react-grab logic (settings, script loader, message handler, BrowserPanel extension) into Sources/Panels/ReactGrab.swift. Add a "React Grab Version" text field in Settings > Browser that lets the user pin which npm version is fetched. Only versions with a known SHA-256 integrity hash in ReactGrabSettings.knownHashes are accepted. The cache invalidates when the configured version changes. --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
5723 lines
226 KiB
Swift
5723 lines
226 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 LastSurfaceCloseShortcutSettings {
|
|
static let key = "closeWorkspaceOnLastSurfaceShortcut"
|
|
// Keep the legacy stored meaning so existing values still map to the same
|
|
// behavior. The default is flipped to preserve current Cmd+W behavior.
|
|
static let defaultValue = true
|
|
|
|
static func closesWorkspace(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 SidebarWorkspaceDetailSettings {
|
|
static let hideAllDetailsKey = "sidebarHideAllDetails"
|
|
static let showNotificationMessageKey = "sidebarShowNotificationMessage"
|
|
static let defaultHideAllDetails = false
|
|
static let defaultShowNotificationMessage = true
|
|
|
|
static func hidesAllDetails(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: hideAllDetailsKey) == nil {
|
|
return defaultHideAllDetails
|
|
}
|
|
return defaults.bool(forKey: hideAllDetailsKey)
|
|
}
|
|
|
|
static func showsNotificationMessage(defaults: UserDefaults = .standard) -> Bool {
|
|
if defaults.object(forKey: showNotificationMessageKey) == nil {
|
|
return defaultShowNotificationMessage
|
|
}
|
|
return defaults.bool(forKey: showNotificationMessageKey)
|
|
}
|
|
|
|
static func resolvedNotificationMessageVisibility(
|
|
showNotificationMessage: Bool,
|
|
hideAllDetails: Bool
|
|
) -> Bool {
|
|
showNotificationMessage && !hideAllDetails
|
|
}
|
|
}
|
|
|
|
struct SidebarWorkspaceAuxiliaryDetailVisibility: Equatable {
|
|
let showsMetadata: Bool
|
|
let showsLog: Bool
|
|
let showsProgress: Bool
|
|
let showsBranchDirectory: Bool
|
|
let showsPullRequests: Bool
|
|
let showsPorts: Bool
|
|
|
|
static let hidden = Self(
|
|
showsMetadata: false,
|
|
showsLog: false,
|
|
showsProgress: false,
|
|
showsBranchDirectory: false,
|
|
showsPullRequests: false,
|
|
showsPorts: false
|
|
)
|
|
|
|
static func resolved(
|
|
showMetadata: Bool,
|
|
showLog: Bool,
|
|
showProgress: Bool,
|
|
showBranchDirectory: Bool,
|
|
showPullRequests: Bool,
|
|
showPorts: Bool,
|
|
hideAllDetails: Bool
|
|
) -> Self {
|
|
guard !hideAllDetails else { return .hidden }
|
|
return Self(
|
|
showsMetadata: showMetadata,
|
|
showsLog: showLog,
|
|
showsProgress: showProgress,
|
|
showsBranchDirectory: showBranchDirectory,
|
|
showsPullRequests: showPullRequests,
|
|
showsPorts: showPorts
|
|
)
|
|
}
|
|
}
|
|
|
|
enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable {
|
|
case leftRail
|
|
case solidFill
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .leftRail:
|
|
return String(localized: "sidebar.activeTabIndicator.leftRail", defaultValue: "Left Rail")
|
|
case .solidFill:
|
|
return String(localized: "sidebar.activeTabIndicator.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 {
|
|
private enum WorkspacePullRequestSnapshot: Equatable {
|
|
case unsupportedRepository
|
|
case notFound
|
|
case resolved(SidebarPullRequestState)
|
|
case transientFailure
|
|
}
|
|
|
|
private struct InitialWorkspaceGitMetadataSnapshot: Equatable {
|
|
let branch: String?
|
|
let isDirty: Bool
|
|
let pullRequest: WorkspacePullRequestSnapshot
|
|
}
|
|
|
|
private struct CommandResult {
|
|
let stdout: String?
|
|
let stderr: String?
|
|
let exitStatus: Int32?
|
|
let timedOut: Bool
|
|
let executionError: String?
|
|
}
|
|
|
|
private struct WorkspaceGitProbeKey: Hashable {
|
|
let workspaceId: UUID
|
|
let panelId: UUID
|
|
}
|
|
|
|
struct GitHubPullRequestProbeItem: Decodable, Equatable {
|
|
let number: Int
|
|
let state: String
|
|
let url: String
|
|
let updatedAt: String?
|
|
}
|
|
|
|
private struct GitHubPullRequestCheckItem: Decodable {
|
|
let bucket: String?
|
|
let state: String?
|
|
}
|
|
|
|
/// 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
|
|
@Published private(set) var pendingBackgroundWorkspaceLoadIds: Set<UUID> = []
|
|
@Published private(set) var debugPinnedWorkspaceLoadIds: Set<UUID> = []
|
|
|
|
/// 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
|
|
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
|
|
private static let workspaceGitMetadataPollInterval: TimeInterval = 30
|
|
private static let selectedWorkspaceGitMetadataPollInterval: TimeInterval = 5
|
|
private nonisolated static let workspacePullRequestProbeTimeout: TimeInterval = 5.0
|
|
@Published var selectedTabId: UUID? {
|
|
willSet {
|
|
#if DEBUG
|
|
guard newValue != selectedTabId else {
|
|
debugPendingWorkspaceSwitchTrigger = nil
|
|
debugPendingWorkspaceSwitchTarget = nil
|
|
debugPreparedWorkspaceSwitchTarget = nil
|
|
return
|
|
}
|
|
|
|
if debugPreparedWorkspaceSwitchTarget == newValue {
|
|
debugPreparedWorkspaceSwitchTarget = nil
|
|
debugPendingWorkspaceSwitchTrigger = nil
|
|
debugPendingWorkspaceSwitchTarget = nil
|
|
} else {
|
|
let trigger = (debugPendingWorkspaceSwitchTarget == newValue
|
|
? debugPendingWorkspaceSwitchTrigger
|
|
: nil) ?? "direct"
|
|
debugPendingWorkspaceSwitchTrigger = nil
|
|
debugPendingWorkspaceSwitchTarget = nil
|
|
debugBeginWorkspaceSwitch(
|
|
trigger: trigger,
|
|
from: selectedTabId,
|
|
to: newValue
|
|
)
|
|
}
|
|
#endif
|
|
}
|
|
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.dismissFocusedPanelNotificationIfActive(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)
|
|
private let initialWorkspaceGitProbeQueue = DispatchQueue(
|
|
label: "com.cmux.initial-workspace-git-probe",
|
|
qos: .utility
|
|
)
|
|
private var workspaceGitProbeGenerationByKey: [WorkspaceGitProbeKey: UUID] = [:]
|
|
private var workspaceGitProbeTimersByKey: [WorkspaceGitProbeKey: [DispatchSourceTimer]] = [:]
|
|
|
|
// 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)?
|
|
private var sidebarSelectedWorkspaceIds: Set<UUID> = []
|
|
var confirmCloseHandler: ((String, String, Bool) -> Bool)?
|
|
private struct WorkspaceCreationTabSnapshot {
|
|
let id: UUID
|
|
let isPinned: Bool
|
|
|
|
@MainActor
|
|
init(workspace: Workspace) {
|
|
self.id = workspace.id
|
|
self.isPinned = workspace.isPinned
|
|
}
|
|
}
|
|
|
|
private struct WorkspaceCreationSnapshot {
|
|
let tabs: [WorkspaceCreationTabSnapshot]
|
|
let selectedTabId: UUID?
|
|
let selectedTabWasPinned: Bool
|
|
let preferredWorkingDirectory: String?
|
|
let inheritedTerminalFontPoints: Float?
|
|
}
|
|
private var agentPIDSweepTimer: DispatchSourceTimer?
|
|
private var workspaceGitMetadataPollTimer: DispatchSourceTimer?
|
|
private var selectedWorkspaceGitMetadataPollTimer: DispatchSourceTimer?
|
|
#if DEBUG
|
|
private var debugWorkspaceSwitchCounter: UInt64 = 0
|
|
private var debugWorkspaceSwitchId: UInt64 = 0
|
|
private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0
|
|
private var debugPendingWorkspaceSwitchTrigger: String?
|
|
private var debugPendingWorkspaceSwitchTarget: UUID?
|
|
private var debugPreparedWorkspaceSwitchTarget: UUID?
|
|
#endif
|
|
|
|
#if DEBUG
|
|
private var didSetupSplitCloseRightUITest = false
|
|
private var didSetupUITestFocusShortcuts = false
|
|
private var didSetupChildExitSplitUITest = false
|
|
private var didSetupChildExitKeyboardUITest = false
|
|
private var uiTestCancellables = Set<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 }
|
|
dismissPanelNotificationOnFocusIfActive(tabId: tabId, panelId: surfaceId)
|
|
}
|
|
})
|
|
|
|
startAgentPIDSweepTimer()
|
|
startWorkspaceGitMetadataPollTimer()
|
|
startSelectedWorkspaceGitMetadataPollTimer()
|
|
#if DEBUG
|
|
setupUITestFocusShortcutsIfNeeded()
|
|
setupSplitCloseRightUITestIfNeeded()
|
|
setupChildExitSplitUITestIfNeeded()
|
|
setupChildExitKeyboardUITestIfNeeded()
|
|
#endif
|
|
}
|
|
|
|
deinit {
|
|
workspaceCycleCooldownTask?.cancel()
|
|
agentPIDSweepTimer?.cancel()
|
|
workspaceGitMetadataPollTimer?.cancel()
|
|
selectedWorkspaceGitMetadataPollTimer?.cancel()
|
|
}
|
|
|
|
// MARK: - Agent PID Sweep
|
|
|
|
/// Periodically checks agent PIDs associated with status entries.
|
|
/// If a process has exited (SIGKILL, crash, etc.), clears the stale status entry.
|
|
/// This is the safety net for cases where no hook fires (e.g. SIGKILL).
|
|
private func startAgentPIDSweepTimer() {
|
|
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
|
|
timer.schedule(deadline: .now() + 30, repeating: 30)
|
|
timer.setEventHandler { [weak self] in
|
|
guard let self else { return }
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.sweepStaleAgentPIDs()
|
|
}
|
|
}
|
|
timer.resume()
|
|
agentPIDSweepTimer = timer
|
|
}
|
|
|
|
/// Periodically refreshes git/PR metadata for tracked workspace branches so
|
|
/// remote GitHub state changes (e.g. PR open -> merged) reach sidebar state
|
|
/// even when the local branch/directory does not change.
|
|
private func startWorkspaceGitMetadataPollTimer() {
|
|
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
|
|
let interval = Self.workspaceGitMetadataPollInterval
|
|
timer.schedule(deadline: .now() + interval, repeating: interval)
|
|
timer.setEventHandler { [weak self] in
|
|
guard let self else { return }
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.refreshTrackedWorkspaceGitMetadata()
|
|
}
|
|
}
|
|
timer.resume()
|
|
workspaceGitMetadataPollTimer = timer
|
|
}
|
|
|
|
/// Refresh the selected workspace more aggressively so branch checkouts and
|
|
/// newly created PRs show up in the sidebar without waiting for the slower
|
|
/// background sweep across every tracked workspace.
|
|
private func startSelectedWorkspaceGitMetadataPollTimer() {
|
|
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
|
|
let interval = Self.selectedWorkspaceGitMetadataPollInterval
|
|
timer.schedule(deadline: .now() + interval, repeating: interval)
|
|
timer.setEventHandler { [weak self] in
|
|
guard let self else { return }
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.refreshSelectedWorkspaceGitMetadata()
|
|
}
|
|
}
|
|
timer.resume()
|
|
selectedWorkspaceGitMetadataPollTimer = timer
|
|
}
|
|
|
|
private func refreshTrackedWorkspaceGitMetadata() {
|
|
let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys)
|
|
|
|
for workspace in tabs {
|
|
for panelId in trackedWorkspaceGitMetadataPollCandidatePanelIds(
|
|
in: workspace,
|
|
activeProbeKeys: activeProbeKeys
|
|
) {
|
|
scheduleWorkspaceGitMetadataRefreshIfPossible(
|
|
workspaceId: workspace.id,
|
|
panelId: panelId,
|
|
reason: "periodicPoll"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func refreshSelectedWorkspaceGitMetadata() {
|
|
guard let workspace = selectedWorkspace,
|
|
let focusedPanelId = workspace.focusedPanelId else {
|
|
return
|
|
}
|
|
|
|
let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys)
|
|
let candidatePanelIds = trackedWorkspaceGitMetadataPollCandidatePanelIds(
|
|
in: workspace,
|
|
activeProbeKeys: activeProbeKeys
|
|
)
|
|
guard candidatePanelIds.contains(focusedPanelId) else { return }
|
|
|
|
scheduleWorkspaceGitMetadataRefreshIfPossible(
|
|
workspaceId: workspace.id,
|
|
panelId: focusedPanelId,
|
|
reason: "selectedPeriodicPoll"
|
|
)
|
|
}
|
|
|
|
func refreshTrackedWorkspaceGitMetadataForTesting() {
|
|
refreshTrackedWorkspaceGitMetadata()
|
|
}
|
|
|
|
func trackedWorkspaceGitMetadataPollCandidatePanelIdsForTesting(workspaceId: UUID) -> Set<UUID> {
|
|
let activeProbeKeys = Set(workspaceGitProbeGenerationByKey.keys)
|
|
guard let workspace = tabs.first(where: { $0.id == workspaceId }) else {
|
|
return []
|
|
}
|
|
return trackedWorkspaceGitMetadataPollCandidatePanelIds(
|
|
in: workspace,
|
|
activeProbeKeys: activeProbeKeys
|
|
)
|
|
}
|
|
|
|
private func trackedWorkspaceGitMetadataPollCandidatePanelIds(
|
|
in workspace: Workspace,
|
|
activeProbeKeys: Set<WorkspaceGitProbeKey>
|
|
) -> Set<UUID> {
|
|
var candidatePanelIds = Set(workspace.panelGitBranches.keys)
|
|
candidatePanelIds.formUnion(workspace.panelPullRequests.keys)
|
|
|
|
if candidatePanelIds.isEmpty,
|
|
let focusedPanelId = workspace.focusedPanelId,
|
|
workspace.gitBranch != nil || workspace.pullRequest != nil {
|
|
candidatePanelIds.insert(focusedPanelId)
|
|
}
|
|
|
|
return Set(candidatePanelIds.filter { panelId in
|
|
let probeKey = WorkspaceGitProbeKey(workspaceId: workspace.id, panelId: panelId)
|
|
return !activeProbeKeys.contains(probeKey)
|
|
})
|
|
}
|
|
|
|
private func sweepStaleAgentPIDs() {
|
|
for tab in tabs {
|
|
var keysToRemove: [String] = []
|
|
for (key, pid) in tab.agentPIDs {
|
|
guard pid > 0 else {
|
|
keysToRemove.append(key)
|
|
continue
|
|
}
|
|
// kill(pid, 0) probes process liveness without sending a signal.
|
|
// ESRCH = process doesn't exist (stale). EPERM = process exists
|
|
// but we lack permission (not stale, keep tracking).
|
|
errno = 0
|
|
if kill(pid, 0) == -1, POSIXErrorCode(rawValue: errno) == .ESRCH {
|
|
keysToRemove.append(key)
|
|
}
|
|
}
|
|
if !keysToRemove.isEmpty {
|
|
for key in keysToRemove {
|
|
tab.statusEntries.removeValue(forKey: key)
|
|
tab.agentPIDs.removeValue(forKey: key)
|
|
}
|
|
// Also clear stale notifications (e.g. "Doing well, thanks!")
|
|
// left behind when Claude was killed without SessionEnd firing.
|
|
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func gitProbeDirectory(for workspace: Workspace, panelId: UUID) -> String? {
|
|
let rawDirectory = workspace.panelDirectories[panelId]
|
|
?? (workspace.focusedPanelId == panelId ? workspace.currentDirectory : nil)
|
|
return rawDirectory.flatMap(normalizedWorkingDirectory)
|
|
}
|
|
|
|
private func scheduleWorkspaceGitMetadataRefreshIfPossible(
|
|
workspaceId: UUID,
|
|
panelId: UUID,
|
|
reason: String,
|
|
delays: [TimeInterval] = [0]
|
|
) {
|
|
guard let workspace = tabs.first(where: { $0.id == workspaceId }),
|
|
workspace.panels[panelId] != nil,
|
|
let directory = gitProbeDirectory(for: workspace, panelId: panelId) else {
|
|
return
|
|
}
|
|
|
|
scheduleWorkspaceGitMetadataRefresh(
|
|
workspaceId: workspaceId,
|
|
panelId: panelId,
|
|
directory: directory,
|
|
delays: delays,
|
|
reason: reason
|
|
)
|
|
}
|
|
|
|
private func wireClosedBrowserTracking(for workspace: Workspace) {
|
|
workspace.onClosedBrowserPanel = { [weak self] snapshot in
|
|
self?.recentlyClosedBrowsers.push(snapshot)
|
|
}
|
|
}
|
|
|
|
private func unwireClosedBrowserTracking(for workspace: Workspace) {
|
|
workspace.onClosedBrowserPanel = nil
|
|
}
|
|
|
|
var selectedWorkspace: Workspace? {
|
|
guard let selectedTabId else { return nil }
|
|
return tabs.first(where: { $0.id == selectedTabId })
|
|
}
|
|
|
|
// Keep selectedTab as convenience alias
|
|
var selectedTab: Workspace? { selectedWorkspace }
|
|
|
|
// MARK: - Surface/Panel Compatibility Layer
|
|
|
|
/// Returns the focused terminal surface for the selected workspace
|
|
var selectedSurface: TerminalSurface? {
|
|
selectedWorkspace?.focusedTerminalPanel?.surface
|
|
}
|
|
|
|
/// Returns the focused panel's terminal panel (if it is a terminal)
|
|
var selectedTerminalPanel: TerminalPanel? {
|
|
selectedWorkspace?.focusedTerminalPanel
|
|
}
|
|
|
|
var isFindVisible: Bool {
|
|
selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil
|
|
}
|
|
|
|
var canUseSelectionForFind: Bool {
|
|
selectedTerminalPanel?.hasSelection() == true
|
|
}
|
|
|
|
func startSearch() {
|
|
if let panel = selectedTerminalPanel {
|
|
if panel.searchState == nil {
|
|
panel.searchState = TerminalSurface.SearchState()
|
|
}
|
|
NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
|
|
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
|
|
_ = panel.performBindingAction("start_search")
|
|
return
|
|
}
|
|
if let panel = selectedTerminalPanel {
|
|
let hadExistingSearch = panel.searchState != nil
|
|
let handled = startOrFocusTerminalSearch(panel.surface)
|
|
NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
|
|
#if DEBUG
|
|
dlog(
|
|
"find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " +
|
|
"panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " +
|
|
"handled=\(handled ? 1 : 0) " +
|
|
"firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))"
|
|
)
|
|
#endif
|
|
return
|
|
}
|
|
|
|
focusedBrowserPanel?.startFind()
|
|
}
|
|
|
|
func searchSelection() {
|
|
guard let panel = selectedTerminalPanel else { return }
|
|
if panel.searchState == nil {
|
|
panel.searchState = TerminalSurface.SearchState()
|
|
}
|
|
NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
|
|
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
|
|
_ = panel.performBindingAction("search_selection")
|
|
}
|
|
|
|
func findNext() {
|
|
if let panel = selectedTerminalPanel {
|
|
_ = panel.performBindingAction("search:next")
|
|
return
|
|
}
|
|
|
|
focusedBrowserPanel?.findNext()
|
|
}
|
|
|
|
func findPrevious() {
|
|
if let panel = selectedTerminalPanel {
|
|
_ = panel.performBindingAction("search:previous")
|
|
return
|
|
}
|
|
|
|
focusedBrowserPanel?.findPrevious()
|
|
}
|
|
|
|
@discardableResult
|
|
func toggleFocusedTerminalCopyMode() -> Bool {
|
|
guard let panel = selectedTerminalPanel else { return false }
|
|
return panel.surface.toggleKeyboardCopyMode()
|
|
}
|
|
|
|
func hideFind() {
|
|
if let panel = selectedTerminalPanel {
|
|
panel.searchState = nil
|
|
return
|
|
}
|
|
|
|
focusedBrowserPanel?.hideFind()
|
|
}
|
|
|
|
func makeWorkspaceForCreation(
|
|
title: String,
|
|
workingDirectory: String?,
|
|
portOrdinal: Int,
|
|
configTemplate: CmuxSurfaceConfigTemplate?,
|
|
initialTerminalCommand: String?,
|
|
initialTerminalEnvironment: [String: String]
|
|
) -> Workspace {
|
|
Workspace(
|
|
title: title,
|
|
workingDirectory: workingDirectory,
|
|
portOrdinal: portOrdinal,
|
|
configTemplate: configTemplate,
|
|
initialTerminalCommand: initialTerminalCommand,
|
|
initialTerminalEnvironment: initialTerminalEnvironment
|
|
)
|
|
}
|
|
|
|
/// Test seam for mutating live workspace state after the creation snapshot is captured.
|
|
func didCaptureWorkspaceCreationSnapshot() {}
|
|
|
|
#if DEBUG
|
|
private func maybeMutateSelectionDuringWorkspaceCreationForDev(
|
|
snapshot: WorkspaceCreationSnapshot
|
|
) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
let isEnabled: Bool = {
|
|
if let raw = env["CMUX_DEV_MUTATE_WORKSPACE_SELECTION_DURING_CREATION"] {
|
|
return raw == "1" || raw.caseInsensitiveCompare("true") == .orderedSame
|
|
}
|
|
return UserDefaults.standard.bool(forKey: "cmuxDevMutateWorkspaceSelectionDuringCreation")
|
|
}()
|
|
guard isEnabled,
|
|
let selectedTabId = snapshot.selectedTabId,
|
|
let targetId = snapshot.tabs.lazy.map(\.id).first(where: { $0 != selectedTabId }),
|
|
tabs.contains(where: { $0.id == targetId }) else {
|
|
return
|
|
}
|
|
dlog(
|
|
"workspace.create.devSelectionMutation from=\(selectedTabId.uuidString.prefix(5)) " +
|
|
"to=\(targetId.uuidString.prefix(5))"
|
|
)
|
|
self.selectedTabId = targetId
|
|
}
|
|
#endif
|
|
|
|
@discardableResult
|
|
func addWorkspace(
|
|
title: String? = nil,
|
|
workingDirectory overrideWorkingDirectory: String? = nil,
|
|
initialTerminalCommand: String? = nil,
|
|
initialTerminalEnvironment: [String: String] = [:],
|
|
select: Bool = true,
|
|
eagerLoadTerminal: Bool = false,
|
|
placementOverride: NewWorkspacePlacement? = nil,
|
|
autoWelcomeIfNeeded: Bool = true
|
|
) -> Workspace {
|
|
let sourceWorkspace = selectedWorkspace
|
|
let capturedTabs = tabs
|
|
// Snapshot the selected tab from the pinned workspace instead of rereading the
|
|
// @Published selectedTabId storage after the inheritance helpers. The arm64 Nightly
|
|
// Cmd+N crash is in PublishedSubject.value.getter on that second getter read.
|
|
let capturedSelectedTabId = sourceWorkspace?.id
|
|
// Keep both the source workspace and the pre-creation workspace array alive for the
|
|
// entire creation path. Release ARC can otherwise drop retains early across the
|
|
// helper/insertion chain, which reintroduces use-after-free crashes in optimized builds.
|
|
return withExtendedLifetime((capturedTabs, sourceWorkspace)) {
|
|
let dir = preferredWorkingDirectoryForNewTab(workspace: sourceWorkspace)
|
|
let font = inheritedTerminalFontPointsForNewWorkspace(workspace: sourceWorkspace)
|
|
let snapshot = workspaceCreationSnapshotLite(
|
|
currentTabs: capturedTabs,
|
|
currentSelectedTabId: capturedSelectedTabId,
|
|
preferredWorkingDirectory: dir,
|
|
inheritedTerminalFontPoints: font
|
|
)
|
|
didCaptureWorkspaceCreationSnapshot()
|
|
#if DEBUG
|
|
maybeMutateSelectionDuringWorkspaceCreationForDev(snapshot: snapshot)
|
|
#endif
|
|
let nextTabCount = snapshot.tabs.count + 1
|
|
sentryBreadcrumb("workspace.create", data: ["tabCount": nextTabCount])
|
|
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
|
|
let workingDirectory = explicitWorkingDirectory ?? snapshot.preferredWorkingDirectory
|
|
let inheritedConfig = workspaceCreationConfigTemplate(
|
|
inheritedTerminalFontPoints: snapshot.inheritedTerminalFontPoints
|
|
)
|
|
// Resolve placement against the pre-creation snapshot before Workspace init
|
|
// boots terminal state. The ssh/new-workspace path can otherwise crash while
|
|
// reading @Published placement state from existing workspaces mid-creation.
|
|
let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
|
|
let ordinal = Self.nextPortOrdinal
|
|
Self.nextPortOrdinal += 1
|
|
let newWorkspace = makeWorkspaceForCreation(
|
|
title: title ?? "Terminal \(nextTabCount)",
|
|
workingDirectory: workingDirectory,
|
|
portOrdinal: ordinal,
|
|
configTemplate: inheritedConfig,
|
|
initialTerminalCommand: initialTerminalCommand,
|
|
initialTerminalEnvironment: initialTerminalEnvironment
|
|
)
|
|
newWorkspace.owningTabManager = self
|
|
if title != nil {
|
|
newWorkspace.setCustomTitle(title)
|
|
}
|
|
wireClosedBrowserTracking(for: newWorkspace)
|
|
if eagerLoadTerminal && !select {
|
|
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
|
|
}
|
|
// Apply insertion to the current live array so post-snapshot closes/reorders
|
|
// are preserved instead of reintroducing stale workspace instances.
|
|
var updatedTabs = tabs
|
|
if insertIndex >= 0 && insertIndex <= updatedTabs.count {
|
|
updatedTabs.insert(newWorkspace, at: insertIndex)
|
|
} else {
|
|
updatedTabs.append(newWorkspace)
|
|
}
|
|
tabs = updatedTabs
|
|
if let explicitWorkingDirectory,
|
|
let terminalPanel = newWorkspace.focusedTerminalPanel {
|
|
scheduleInitialWorkspaceGitMetadataRefresh(
|
|
workspaceId: newWorkspace.id,
|
|
panelId: terminalPanel.id,
|
|
directory: explicitWorkingDirectory
|
|
)
|
|
}
|
|
if eagerLoadTerminal {
|
|
if select {
|
|
newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded()
|
|
}
|
|
}
|
|
if select {
|
|
#if DEBUG
|
|
debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id)
|
|
#endif
|
|
selectedTabId = newWorkspace.id
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidFocusTab,
|
|
object: nil,
|
|
userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id]
|
|
)
|
|
}
|
|
#if DEBUG
|
|
UITestRecorder.incrementInt("addTabInvocations")
|
|
UITestRecorder.record([
|
|
"tabCount": String(updatedTabs.count),
|
|
"selectedTabId": select ? newWorkspace.id.uuidString : (snapshot.selectedTabId?.uuidString ?? "")
|
|
])
|
|
#endif
|
|
if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) {
|
|
if let appDelegate = AppDelegate.shared {
|
|
appDelegate.sendWelcomeCommandWhenReady(to: newWorkspace, markShownOnSend: true)
|
|
} else {
|
|
sendWelcomeWhenReady(to: newWorkspace)
|
|
}
|
|
}
|
|
return newWorkspace
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func sendWelcomeWhenReady(to workspace: Workspace) {
|
|
if let terminalPanel = workspace.focusedTerminalPanel,
|
|
terminalPanel.surface.surface != nil {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
|
terminalPanel.sendText("cmux welcome\n")
|
|
}
|
|
return
|
|
}
|
|
|
|
var resolved = false
|
|
var readyObserver: NSObjectProtocol?
|
|
var panelsCancellable: AnyCancellable?
|
|
|
|
func finishIfReady() {
|
|
guard !resolved,
|
|
let terminalPanel = workspace.focusedTerminalPanel,
|
|
terminalPanel.surface.surface != nil else { return }
|
|
resolved = true
|
|
if let readyObserver {
|
|
NotificationCenter.default.removeObserver(readyObserver)
|
|
}
|
|
panelsCancellable?.cancel()
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
|
terminalPanel.sendText("cmux welcome\n")
|
|
}
|
|
}
|
|
|
|
panelsCancellable = workspace.$panels
|
|
.map { _ in () }
|
|
.sink { _ in
|
|
Task { @MainActor in
|
|
finishIfReady()
|
|
}
|
|
}
|
|
readyObserver = NotificationCenter.default.addObserver(
|
|
forName: .terminalSurfaceDidBecomeReady,
|
|
object: nil,
|
|
queue: .main
|
|
) { note in
|
|
guard let workspaceId = note.userInfo?["workspaceId"] as? UUID,
|
|
workspaceId == workspace.id else { return }
|
|
Task { @MainActor in
|
|
finishIfReady()
|
|
}
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
|
Task { @MainActor in
|
|
if let readyObserver, !resolved {
|
|
NotificationCenter.default.removeObserver(readyObserver)
|
|
}
|
|
if !resolved {
|
|
panelsCancellable?.cancel()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scheduleInitialWorkspaceGitMetadataRefresh(
|
|
workspaceId: UUID,
|
|
panelId: UUID,
|
|
directory: String
|
|
) {
|
|
scheduleWorkspaceGitMetadataRefresh(
|
|
workspaceId: workspaceId,
|
|
panelId: panelId,
|
|
directory: directory,
|
|
delays: Self.initialWorkspaceGitProbeDelays,
|
|
reason: "initial"
|
|
)
|
|
}
|
|
|
|
private func scheduleWorkspaceGitMetadataRefresh(
|
|
workspaceId: UUID,
|
|
panelId: UUID,
|
|
directory: String,
|
|
delays: [TimeInterval],
|
|
reason: String
|
|
) {
|
|
let normalizedDirectory = normalizeDirectory(directory)
|
|
let key = WorkspaceGitProbeKey(workspaceId: workspaceId, panelId: panelId)
|
|
let generation = UUID()
|
|
cancelWorkspaceGitProbeTimers(for: key)
|
|
workspaceGitProbeGenerationByKey[key] = generation
|
|
|
|
#if DEBUG
|
|
dlog(
|
|
"workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " +
|
|
"panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory) reason=\(reason)"
|
|
)
|
|
#endif
|
|
|
|
var timers: [DispatchSourceTimer] = []
|
|
for (index, delay) in delays.enumerated() {
|
|
let isLastAttempt = index == delays.count - 1
|
|
let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue)
|
|
timer.schedule(deadline: .now() + delay, repeating: .never)
|
|
timer.setEventHandler { [weak self] in
|
|
let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory)
|
|
Task { @MainActor [weak self] in
|
|
self?.applyWorkspaceGitMetadataSnapshot(
|
|
snapshot,
|
|
generation: generation,
|
|
probeKey: key,
|
|
expectedDirectory: normalizedDirectory,
|
|
isLastAttempt: isLastAttempt
|
|
)
|
|
}
|
|
}
|
|
timers.append(timer)
|
|
timer.resume()
|
|
}
|
|
workspaceGitProbeTimersByKey[key] = timers
|
|
}
|
|
|
|
private func cancelWorkspaceGitProbeTimers(for key: WorkspaceGitProbeKey) {
|
|
guard let timers = workspaceGitProbeTimersByKey.removeValue(forKey: key) else {
|
|
return
|
|
}
|
|
for timer in timers {
|
|
timer.setEventHandler {}
|
|
timer.cancel()
|
|
}
|
|
}
|
|
|
|
private func clearWorkspaceGitProbe(_ key: WorkspaceGitProbeKey) {
|
|
workspaceGitProbeGenerationByKey.removeValue(forKey: key)
|
|
cancelWorkspaceGitProbeTimers(for: key)
|
|
}
|
|
|
|
private func clearWorkspaceGitProbes(workspaceId: UUID) {
|
|
let keys = Set(workspaceGitProbeGenerationByKey.keys.filter { $0.workspaceId == workspaceId })
|
|
.union(workspaceGitProbeTimersByKey.keys.filter { $0.workspaceId == workspaceId })
|
|
for key in keys {
|
|
clearWorkspaceGitProbe(key)
|
|
}
|
|
}
|
|
|
|
private func applyWorkspaceGitMetadataSnapshot(
|
|
_ snapshot: InitialWorkspaceGitMetadataSnapshot,
|
|
generation: UUID,
|
|
probeKey: WorkspaceGitProbeKey,
|
|
expectedDirectory: String,
|
|
isLastAttempt: Bool
|
|
) {
|
|
defer {
|
|
if shouldStopWorkspaceGitMetadataRefresh(snapshot) || isLastAttempt,
|
|
workspaceGitProbeGenerationByKey[probeKey] == generation {
|
|
clearWorkspaceGitProbe(probeKey)
|
|
}
|
|
}
|
|
|
|
guard workspaceGitProbeGenerationByKey[probeKey] == generation else { return }
|
|
guard let workspace = tabs.first(where: { $0.id == probeKey.workspaceId }) else {
|
|
clearWorkspaceGitProbe(probeKey)
|
|
return
|
|
}
|
|
guard workspace.panels[probeKey.panelId] != nil else {
|
|
clearWorkspaceGitProbe(probeKey)
|
|
return
|
|
}
|
|
|
|
guard let currentDirectory = gitProbeDirectory(for: workspace, panelId: probeKey.panelId) else {
|
|
clearWorkspaceGitProbe(probeKey)
|
|
return
|
|
}
|
|
if currentDirectory != expectedDirectory {
|
|
clearWorkspaceGitProbe(probeKey)
|
|
#if DEBUG
|
|
dlog(
|
|
"workspace.gitProbe.skip workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " +
|
|
"panel=\(probeKey.panelId.uuidString.prefix(5)) reason=directoryChanged " +
|
|
"expected=\(expectedDirectory) current=\(currentDirectory)"
|
|
)
|
|
#endif
|
|
return
|
|
}
|
|
|
|
workspace.updatePanelDirectory(panelId: probeKey.panelId, directory: expectedDirectory)
|
|
|
|
let nextBranch = snapshot.branch
|
|
if let nextBranch {
|
|
workspace.updatePanelGitBranch(
|
|
panelId: probeKey.panelId,
|
|
branch: nextBranch,
|
|
isDirty: snapshot.isDirty
|
|
)
|
|
} else {
|
|
workspace.clearPanelGitBranch(panelId: probeKey.panelId)
|
|
}
|
|
|
|
switch snapshot.pullRequest {
|
|
case .resolved(let pullRequest):
|
|
workspace.updatePanelPullRequest(
|
|
panelId: probeKey.panelId,
|
|
number: pullRequest.number,
|
|
label: pullRequest.label,
|
|
url: pullRequest.url,
|
|
status: pullRequest.status,
|
|
checks: pullRequest.checks
|
|
)
|
|
case .notFound:
|
|
if workspace.panelPullRequests[probeKey.panelId] != nil {
|
|
workspace.clearPanelPullRequest(panelId: probeKey.panelId)
|
|
}
|
|
case .unsupportedRepository, .transientFailure:
|
|
break
|
|
}
|
|
|
|
#if DEBUG
|
|
let branchLabel = snapshot.branch ?? "none"
|
|
let prLabel: String = {
|
|
switch snapshot.pullRequest {
|
|
case .unsupportedRepository:
|
|
return "unsupported"
|
|
case .notFound:
|
|
return "none"
|
|
case .transientFailure:
|
|
return "transientFailure"
|
|
case .resolved(let pullRequest):
|
|
let checks = pullRequest.checks?.rawValue ?? "none"
|
|
return "#\(pullRequest.number):\(pullRequest.status.rawValue):\(checks)"
|
|
}
|
|
}()
|
|
dlog(
|
|
"workspace.gitProbe.apply workspace=\(probeKey.workspaceId.uuidString.prefix(5)) " +
|
|
"panel=\(probeKey.panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " +
|
|
"pr=\(prLabel)"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
private func shouldStopWorkspaceGitMetadataRefresh(
|
|
_ snapshot: InitialWorkspaceGitMetadataSnapshot
|
|
) -> Bool {
|
|
switch snapshot.pullRequest {
|
|
case .transientFailure:
|
|
return false
|
|
case .unsupportedRepository, .notFound, .resolved:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private nonisolated static func initialWorkspaceGitMetadataSnapshot(
|
|
for directory: String
|
|
) -> InitialWorkspaceGitMetadataSnapshot {
|
|
let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"]))
|
|
guard let branch else {
|
|
return InitialWorkspaceGitMetadataSnapshot(
|
|
branch: nil,
|
|
isDirty: false,
|
|
pullRequest: .notFound
|
|
)
|
|
}
|
|
|
|
let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"])
|
|
let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
|
let pullRequest = workspacePullRequestSnapshot(directory: directory, branch: branch)
|
|
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest)
|
|
}
|
|
|
|
private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? {
|
|
runCommand(
|
|
directory: directory,
|
|
executable: "git",
|
|
arguments: arguments
|
|
)
|
|
}
|
|
|
|
private nonisolated static func workspacePullRequestSnapshot(
|
|
directory: String,
|
|
branch: String
|
|
) -> WorkspacePullRequestSnapshot {
|
|
guard !shouldSkipWorkspacePullRequestLookup(branch: branch) else {
|
|
return .notFound
|
|
}
|
|
|
|
let repoSlugs = githubRepositorySlugs(directory: directory)
|
|
guard !repoSlugs.isEmpty else {
|
|
return .unsupportedRepository
|
|
}
|
|
|
|
var sawTransientFailure = false
|
|
for repoSlug in repoSlugs {
|
|
switch workspacePullRequestSnapshot(directory: directory, branch: branch, repoSlug: repoSlug) {
|
|
case .resolved(let pullRequest):
|
|
return .resolved(pullRequest)
|
|
case .transientFailure:
|
|
sawTransientFailure = true
|
|
case .notFound, .unsupportedRepository:
|
|
continue
|
|
}
|
|
}
|
|
|
|
return sawTransientFailure ? .transientFailure : .notFound
|
|
}
|
|
|
|
private nonisolated static func workspacePullRequestSnapshot(
|
|
directory: String,
|
|
branch: String,
|
|
repoSlug: String
|
|
) -> WorkspacePullRequestSnapshot {
|
|
let result = runCommandResult(
|
|
directory: directory,
|
|
executable: "gh",
|
|
arguments: [
|
|
"pr", "list",
|
|
"--repo", repoSlug,
|
|
"--state", "all",
|
|
"--head", branch,
|
|
"--json", "number,state,url,updatedAt",
|
|
],
|
|
timeout: workspacePullRequestProbeTimeout
|
|
)
|
|
|
|
guard let result else {
|
|
#if DEBUG
|
|
dlog(
|
|
"workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " +
|
|
"repo=\(repoSlug) status=nil"
|
|
)
|
|
#endif
|
|
return .transientFailure
|
|
}
|
|
|
|
guard !result.timedOut,
|
|
result.executionError == nil,
|
|
let exitStatus = result.exitStatus else {
|
|
#if DEBUG
|
|
let statusText: String
|
|
if result.timedOut {
|
|
statusText = "timeout"
|
|
} else if let executionError = result.executionError {
|
|
statusText = "error=\(executionError)"
|
|
} else {
|
|
statusText = "unknown"
|
|
}
|
|
let stderr = debugLogSnippet(result.stderr) ?? "none"
|
|
dlog(
|
|
"workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " +
|
|
"repo=\(repoSlug) status=\(statusText) stderr=\(stderr)"
|
|
)
|
|
#endif
|
|
return .transientFailure
|
|
}
|
|
|
|
if exitStatus != 0 {
|
|
#if DEBUG
|
|
dlog(
|
|
"workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " +
|
|
"repo=\(repoSlug) status=exit=\(exitStatus) stderr=\(debugLogSnippet(result.stderr) ?? "none")"
|
|
)
|
|
#endif
|
|
return .transientFailure
|
|
}
|
|
|
|
let output = result.stdout ?? ""
|
|
guard let pullRequests = decodeJSON([GitHubPullRequestProbeItem].self, from: output) else {
|
|
#if DEBUG
|
|
dlog(
|
|
"workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " +
|
|
"repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")"
|
|
)
|
|
#endif
|
|
return .transientFailure
|
|
}
|
|
|
|
guard let pullRequest = preferredPullRequest(from: pullRequests) else {
|
|
#if DEBUG
|
|
dlog(
|
|
"workspace.gitProbe.pr.none dir=\(directory) branch=\(branch) " +
|
|
"repo=\(repoSlug)"
|
|
)
|
|
#endif
|
|
return .notFound
|
|
}
|
|
|
|
guard let status = pullRequestStatus(from: pullRequest.state),
|
|
let url = URL(string: pullRequest.url) else {
|
|
#if DEBUG
|
|
dlog(
|
|
"workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " +
|
|
"repo=\(repoSlug) output=\(debugLogSnippet(output) ?? "none")"
|
|
)
|
|
#endif
|
|
return .transientFailure
|
|
}
|
|
|
|
let checks = status == .open
|
|
? pullRequestChecksStatus(number: pullRequest.number, directory: directory, repoSlug: repoSlug)
|
|
: nil
|
|
|
|
#if DEBUG
|
|
dlog(
|
|
"workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " +
|
|
"repo=\(repoSlug) number=\(pullRequest.number) state=\(status.rawValue) checks=\(checks?.rawValue ?? "none")"
|
|
)
|
|
#endif
|
|
return .resolved(
|
|
SidebarPullRequestState(
|
|
number: pullRequest.number,
|
|
label: "PR",
|
|
url: url,
|
|
status: status,
|
|
branch: branch,
|
|
checks: checks
|
|
)
|
|
)
|
|
}
|
|
|
|
nonisolated static func preferredPullRequest(
|
|
from pullRequests: [GitHubPullRequestProbeItem]
|
|
) -> GitHubPullRequestProbeItem? {
|
|
func statusPriority(_ status: SidebarPullRequestStatus) -> Int {
|
|
switch status {
|
|
case .open:
|
|
return 3
|
|
case .merged:
|
|
return 2
|
|
case .closed:
|
|
return 1
|
|
}
|
|
}
|
|
|
|
func isPreferred(
|
|
candidate: GitHubPullRequestProbeItem,
|
|
over current: GitHubPullRequestProbeItem
|
|
) -> Bool {
|
|
guard let candidateStatus = pullRequestStatus(from: candidate.state),
|
|
let currentStatus = pullRequestStatus(from: current.state) else {
|
|
return false
|
|
}
|
|
|
|
let candidatePriority = statusPriority(candidateStatus)
|
|
let currentPriority = statusPriority(currentStatus)
|
|
if candidatePriority != currentPriority {
|
|
return candidatePriority > currentPriority
|
|
}
|
|
|
|
let candidateUpdatedAt = candidate.updatedAt ?? ""
|
|
let currentUpdatedAt = current.updatedAt ?? ""
|
|
if candidateUpdatedAt != currentUpdatedAt {
|
|
return candidateUpdatedAt > currentUpdatedAt
|
|
}
|
|
|
|
return candidate.number > current.number
|
|
}
|
|
|
|
var best: GitHubPullRequestProbeItem?
|
|
for pullRequest in pullRequests {
|
|
guard pullRequestStatus(from: pullRequest.state) != nil,
|
|
URL(string: pullRequest.url) != nil else {
|
|
continue
|
|
}
|
|
guard let currentBest = best else {
|
|
best = pullRequest
|
|
continue
|
|
}
|
|
if isPreferred(candidate: pullRequest, over: currentBest) {
|
|
best = pullRequest
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
private nonisolated static func pullRequestChecksStatus(
|
|
number: Int,
|
|
directory: String,
|
|
repoSlug: String
|
|
) -> SidebarPullRequestChecksStatus? {
|
|
let result = runCommandResult(
|
|
directory: directory,
|
|
executable: "gh",
|
|
arguments: [
|
|
"pr", "checks", String(number),
|
|
"--repo", repoSlug,
|
|
"--json", "bucket,state"
|
|
],
|
|
timeout: workspacePullRequestProbeTimeout
|
|
)
|
|
|
|
guard let result,
|
|
!result.timedOut,
|
|
result.executionError == nil,
|
|
let output = result.stdout,
|
|
let exitStatus = result.exitStatus,
|
|
exitStatus == 0 || exitStatus == 8,
|
|
let checks = decodeJSON([GitHubPullRequestCheckItem].self, from: output) else {
|
|
return nil
|
|
}
|
|
|
|
var sawPending = false
|
|
var sawPass = false
|
|
|
|
for check in checks {
|
|
let bucket = check.bucket?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
let state = check.state?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
|
|
if isFailingCheckState(bucket: bucket, state: state) {
|
|
return .fail
|
|
}
|
|
if isPendingCheckState(bucket: bucket, state: state) {
|
|
sawPending = true
|
|
continue
|
|
}
|
|
if isPassingCheckState(bucket: bucket, state: state) {
|
|
sawPass = true
|
|
}
|
|
}
|
|
|
|
if sawPending {
|
|
return .pending
|
|
}
|
|
if sawPass {
|
|
return .pass
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private nonisolated static func pullRequestStatus(
|
|
from rawState: String
|
|
) -> SidebarPullRequestStatus? {
|
|
switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() {
|
|
case "OPEN":
|
|
return .open
|
|
case "MERGED":
|
|
return .merged
|
|
case "CLOSED":
|
|
return .closed
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private nonisolated static func decodeJSON<T: Decodable>(_ type: T.Type, from text: String) -> T? {
|
|
guard let data = text.data(using: .utf8) else { return nil }
|
|
return try? JSONDecoder().decode(T.self, from: data)
|
|
}
|
|
|
|
private nonisolated static func isFailingCheckState(bucket: String?, state: String?) -> Bool {
|
|
switch bucket ?? state ?? "" {
|
|
case "fail", "failure", "failed", "error", "timed_out", "timedout",
|
|
"cancel", "cancelled", "canceled", "action_required", "startup_failure":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private nonisolated static func isPendingCheckState(bucket: String?, state: String?) -> Bool {
|
|
switch bucket ?? state ?? "" {
|
|
case "pending", "queued", "in_progress", "requested", "waiting", "expected":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private nonisolated static func isPassingCheckState(bucket: String?, state: String?) -> Bool {
|
|
switch bucket ?? state ?? "" {
|
|
case "pass", "success", "successful", "completed", "neutral", "skipping", "skipped":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private nonisolated static let fallbackCommandSearchDirectories: [String] = [
|
|
"/opt/homebrew/bin",
|
|
"/usr/local/bin",
|
|
"/opt/local/bin",
|
|
]
|
|
|
|
nonisolated static func resolvedCommandPathForTesting(
|
|
executable: String,
|
|
environment: [String: String],
|
|
fallbackDirectories: [String]
|
|
) -> String? {
|
|
resolvedCommandPath(
|
|
executable: executable,
|
|
environment: environment,
|
|
fallbackDirectories: fallbackDirectories
|
|
)
|
|
}
|
|
|
|
private nonisolated static func resolvedCommandPath(
|
|
executable: String,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment,
|
|
fallbackDirectories: [String] = fallbackCommandSearchDirectories
|
|
) -> String? {
|
|
guard !executable.isEmpty else { return nil }
|
|
let fileManager = FileManager.default
|
|
if executable.contains("/") {
|
|
return fileManager.isExecutableFile(atPath: executable) ? executable : nil
|
|
}
|
|
|
|
var searchDirectories: [String] = []
|
|
var seenDirectories: Set<String> = []
|
|
|
|
func appendSearchPath(_ path: String?) {
|
|
guard let path else { return }
|
|
for rawComponent in path.split(separator: ":") {
|
|
let component = String(rawComponent).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !component.isEmpty,
|
|
seenDirectories.insert(component).inserted else {
|
|
continue
|
|
}
|
|
searchDirectories.append(component)
|
|
}
|
|
}
|
|
|
|
appendSearchPath(environment["PATH"])
|
|
appendSearchPath(getenv("PATH").map { String(cString: $0) })
|
|
if let bundledBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path {
|
|
appendSearchPath(bundledBinPath)
|
|
}
|
|
fallbackDirectories.forEach { appendSearchPath($0) }
|
|
appendSearchPath("/usr/bin:/bin:/usr/sbin:/sbin")
|
|
|
|
for directory in searchDirectories {
|
|
let candidate = URL(fileURLWithPath: directory, isDirectory: true)
|
|
.appendingPathComponent(executable)
|
|
.path
|
|
if fileManager.isExecutableFile(atPath: candidate) {
|
|
return candidate
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private nonisolated static func runCommand(
|
|
directory: String,
|
|
executable: String,
|
|
arguments: [String],
|
|
timeout: TimeInterval? = nil
|
|
) -> String? {
|
|
let result = runCommandResult(
|
|
directory: directory,
|
|
executable: executable,
|
|
arguments: arguments,
|
|
timeout: timeout
|
|
)
|
|
guard let result,
|
|
result.exitStatus == 0,
|
|
!result.timedOut else {
|
|
return nil
|
|
}
|
|
return result.stdout
|
|
}
|
|
|
|
private nonisolated static func runCommandResult(
|
|
directory: String,
|
|
executable: String,
|
|
arguments: [String],
|
|
timeout: TimeInterval? = nil
|
|
) -> CommandResult? {
|
|
let process = Process()
|
|
let stdout = Pipe()
|
|
let stderr = Pipe()
|
|
if let resolvedExecutable = resolvedCommandPath(executable: executable) {
|
|
process.executableURL = URL(fileURLWithPath: resolvedExecutable)
|
|
process.arguments = arguments
|
|
} else {
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
|
process.arguments = [executable] + arguments
|
|
}
|
|
process.currentDirectoryURL = URL(fileURLWithPath: directory)
|
|
process.standardOutput = stdout
|
|
process.standardError = stderr
|
|
|
|
let completion = DispatchSemaphore(value: 0)
|
|
process.terminationHandler = { _ in
|
|
completion.signal()
|
|
}
|
|
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
return CommandResult(
|
|
stdout: nil,
|
|
stderr: nil,
|
|
exitStatus: nil,
|
|
timedOut: false,
|
|
executionError: String(describing: error)
|
|
)
|
|
}
|
|
|
|
if let timeout,
|
|
completion.wait(timeout: .now() + timeout) == .timedOut {
|
|
process.terminate()
|
|
if completion.wait(timeout: .now() + 0.2) == .timedOut {
|
|
kill(process.processIdentifier, SIGKILL)
|
|
_ = completion.wait(timeout: .now() + 0.2)
|
|
}
|
|
return CommandResult(
|
|
stdout: nil,
|
|
stderr: nil,
|
|
exitStatus: nil,
|
|
timedOut: true,
|
|
executionError: nil
|
|
)
|
|
} else if timeout == nil {
|
|
completion.wait()
|
|
}
|
|
|
|
let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile()
|
|
let stderrData = stderr.fileHandleForReading.readDataToEndOfFile()
|
|
return CommandResult(
|
|
stdout: String(data: stdoutData, encoding: .utf8),
|
|
stderr: String(data: stderrData, encoding: .utf8),
|
|
exitStatus: process.terminationStatus,
|
|
timedOut: false,
|
|
executionError: nil
|
|
)
|
|
}
|
|
|
|
nonisolated static func githubRepositorySlugs(fromGitRemoteVOutput output: String) -> [String] {
|
|
var slugByRemoteName: [String: String] = [:]
|
|
|
|
for line in output.split(whereSeparator: \.isNewline) {
|
|
let parts = line.split(whereSeparator: \.isWhitespace)
|
|
guard parts.count >= 3 else { continue }
|
|
|
|
let remoteName = String(parts[0])
|
|
let remoteURL = String(parts[1])
|
|
let remoteKind = String(parts[2])
|
|
guard remoteKind == "(fetch)",
|
|
let repoSlug = githubRepositorySlug(fromRemoteURL: remoteURL) else {
|
|
continue
|
|
}
|
|
|
|
if slugByRemoteName[remoteName] == nil {
|
|
slugByRemoteName[remoteName] = repoSlug
|
|
}
|
|
}
|
|
|
|
let orderedRemoteNames = slugByRemoteName.keys.sorted { lhs, rhs in
|
|
let lhsPriority = githubRemotePriority(lhs)
|
|
let rhsPriority = githubRemotePriority(rhs)
|
|
if lhsPriority != rhsPriority {
|
|
return lhsPriority < rhsPriority
|
|
}
|
|
return lhs < rhs
|
|
}
|
|
|
|
var orderedSlugs: [String] = []
|
|
var seen: Set<String> = []
|
|
for remoteName in orderedRemoteNames {
|
|
guard let repoSlug = slugByRemoteName[remoteName],
|
|
seen.insert(repoSlug).inserted else {
|
|
continue
|
|
}
|
|
orderedSlugs.append(repoSlug)
|
|
}
|
|
return orderedSlugs
|
|
}
|
|
|
|
private nonisolated static func githubRepositorySlugs(directory: String) -> [String] {
|
|
guard let output = runGitCommand(directory: directory, arguments: ["remote", "-v"]) else {
|
|
return []
|
|
}
|
|
return githubRepositorySlugs(fromGitRemoteVOutput: output)
|
|
}
|
|
|
|
private nonisolated static func githubRemotePriority(_ remoteName: String) -> Int {
|
|
switch remoteName.lowercased() {
|
|
case "upstream":
|
|
return 0
|
|
case "origin":
|
|
return 1
|
|
default:
|
|
return 2
|
|
}
|
|
}
|
|
|
|
private nonisolated static func githubRepositorySlug(fromRemoteURL remoteURL: String) -> String? {
|
|
let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
let githubPrefixes = [
|
|
"git@github.com:",
|
|
"ssh://git@github.com/",
|
|
"https://github.com/",
|
|
"http://github.com/",
|
|
"git://github.com/",
|
|
]
|
|
for prefix in githubPrefixes where trimmed.hasPrefix(prefix) {
|
|
let path = String(trimmed.dropFirst(prefix.count))
|
|
return normalizedGitHubRepositorySlug(path)
|
|
}
|
|
|
|
guard let url = URL(string: trimmed),
|
|
let host = url.host?.lowercased(),
|
|
host == "github.com" else {
|
|
return nil
|
|
}
|
|
|
|
return normalizedGitHubRepositorySlug(url.path)
|
|
}
|
|
|
|
private nonisolated static func normalizedGitHubRepositorySlug(_ rawPath: String) -> String? {
|
|
let trimmedPath = rawPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
guard !trimmedPath.isEmpty else { return nil }
|
|
let components = trimmedPath.split(separator: "/").map(String.init)
|
|
guard components.count >= 2 else { return nil }
|
|
let owner = components[0]
|
|
var repo = components[1]
|
|
if repo.hasSuffix(".git") {
|
|
repo.removeLast(4)
|
|
}
|
|
guard !owner.isEmpty, !repo.isEmpty else { return nil }
|
|
return "\(owner)/\(repo)"
|
|
}
|
|
|
|
private nonisolated static func debugLogSnippet(_ value: String?) -> String? {
|
|
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !trimmed.isEmpty else { return nil }
|
|
return String(trimmed.prefix(180))
|
|
}
|
|
|
|
private nonisolated static func normalizedBranchName(_ branch: String?) -> String? {
|
|
let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
nonisolated static func shouldSkipWorkspacePullRequestLookup(branch: String) -> Bool {
|
|
switch normalizedBranchName(branch) {
|
|
case "main", "master":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
|
guard !pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return }
|
|
var updated = pendingBackgroundWorkspaceLoadIds
|
|
updated.insert(workspaceId)
|
|
pendingBackgroundWorkspaceLoadIds = updated
|
|
}
|
|
|
|
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
|
guard pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return }
|
|
var updated = pendingBackgroundWorkspaceLoadIds
|
|
updated.remove(workspaceId)
|
|
pendingBackgroundWorkspaceLoadIds = updated
|
|
}
|
|
|
|
func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
|
|
guard !workspaceIds.isEmpty else { return }
|
|
var updated = debugPinnedWorkspaceLoadIds
|
|
updated.formUnion(workspaceIds)
|
|
guard updated != debugPinnedWorkspaceLoadIds else { return }
|
|
debugPinnedWorkspaceLoadIds = updated
|
|
}
|
|
|
|
func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) {
|
|
guard !workspaceIds.isEmpty else { return }
|
|
var updated = debugPinnedWorkspaceLoadIds
|
|
updated.subtract(workspaceIds)
|
|
guard updated != debugPinnedWorkspaceLoadIds else { return }
|
|
debugPinnedWorkspaceLoadIds = updated
|
|
}
|
|
|
|
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
|
|
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
|
|
if pruned != pendingBackgroundWorkspaceLoadIds {
|
|
pendingBackgroundWorkspaceLoadIds = pruned
|
|
}
|
|
let retained = debugPinnedWorkspaceLoadIds.intersection(existingIds)
|
|
if retained != debugPinnedWorkspaceLoadIds {
|
|
debugPinnedWorkspaceLoadIds = retained
|
|
}
|
|
}
|
|
|
|
// Keep addTab as convenience alias
|
|
@discardableResult
|
|
func addTab(select: Bool = true, eagerLoadTerminal: Bool = false) -> Workspace {
|
|
addWorkspace(select: select, eagerLoadTerminal: eagerLoadTerminal)
|
|
}
|
|
|
|
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
|
|
terminalPanelForWorkspaceConfigInheritanceSource(workspace: selectedWorkspace)
|
|
}
|
|
|
|
/// Build a snapshot using pre-extracted value-type data. The caller is responsible
|
|
/// for obtaining `preferredWorkingDirectory` and `inheritedTerminalFontPoints` through
|
|
/// `self` (where `self.tabs` keeps all Workspace objects alive) so that no local
|
|
/// Workspace references are needed here.
|
|
private func workspaceCreationSnapshotLite(
|
|
currentTabs: [Workspace],
|
|
currentSelectedTabId: UUID?,
|
|
preferredWorkingDirectory: String?,
|
|
inheritedTerminalFontPoints: Float?
|
|
) -> WorkspaceCreationSnapshot {
|
|
var tabSnapshots: [WorkspaceCreationTabSnapshot] = []
|
|
tabSnapshots.reserveCapacity(currentTabs.count)
|
|
for workspace in currentTabs {
|
|
// Keep each Workspace alive while copying the tiny value snapshot out of it.
|
|
// The optimized arm64 Nightly build can otherwise over-release during
|
|
// Collection.map, crashing here in swift_release / snapshot creation.
|
|
let snapshot = withExtendedLifetime(workspace) {
|
|
WorkspaceCreationTabSnapshot(workspace: workspace)
|
|
}
|
|
tabSnapshots.append(snapshot)
|
|
}
|
|
let selectedTabSnapshot = currentSelectedTabId.flatMap { selectedTabId in
|
|
tabSnapshots.first(where: { $0.id == selectedTabId })
|
|
}
|
|
|
|
return WorkspaceCreationSnapshot(
|
|
tabs: tabSnapshots,
|
|
selectedTabId: currentSelectedTabId,
|
|
selectedTabWasPinned: selectedTabSnapshot?.isPinned ?? false,
|
|
preferredWorkingDirectory: preferredWorkingDirectory,
|
|
inheritedTerminalFontPoints: inheritedTerminalFontPoints
|
|
)
|
|
}
|
|
|
|
private func workspaceCreationSnapshot() -> WorkspaceCreationSnapshot {
|
|
workspaceCreationSnapshotLite(
|
|
currentTabs: tabs,
|
|
currentSelectedTabId: selectedTabId,
|
|
preferredWorkingDirectory: preferredWorkingDirectoryForNewTab(),
|
|
inheritedTerminalFontPoints: inheritedTerminalFontPointsForNewWorkspace()
|
|
)
|
|
}
|
|
|
|
private func orderedLiveWorkspaceCreationTabs(
|
|
from snapshot: WorkspaceCreationSnapshot
|
|
) -> [WorkspaceCreationTabSnapshot]? {
|
|
let currentTabs = tabs
|
|
let snapshotTabsById = Dictionary(uniqueKeysWithValues: snapshot.tabs.map { ($0.id, $0) })
|
|
var orderedTabs: [WorkspaceCreationTabSnapshot] = []
|
|
orderedTabs.reserveCapacity(currentTabs.count)
|
|
|
|
for workspace in currentTabs {
|
|
guard let tabSnapshot = snapshotTabsById[workspace.id] else {
|
|
#if DEBUG
|
|
dlog(
|
|
"workspace.create.reentrantSnapshotFallback " +
|
|
"snapshotCount=\(snapshot.tabs.count) liveCount=\(currentTabs.count)"
|
|
)
|
|
#endif
|
|
return nil
|
|
}
|
|
orderedTabs.append(tabSnapshot)
|
|
}
|
|
|
|
return orderedTabs
|
|
}
|
|
|
|
private func terminalPanelForWorkspaceConfigInheritanceSource(
|
|
workspace: Workspace?
|
|
) -> TerminalPanel? {
|
|
guard let workspace else { return nil }
|
|
// Prefer cached/published panel state here instead of walking live Bonsplit focus
|
|
// during Cmd+N; rapid workspace creation can observe transient pane/tab selection.
|
|
let panels = workspace.panels
|
|
var candidates: [TerminalPanel] = []
|
|
var seen: Set<UUID> = []
|
|
|
|
func appendCandidate(_ panel: TerminalPanel?) {
|
|
guard let panel, seen.insert(panel.id).inserted else { return }
|
|
candidates.append(panel)
|
|
}
|
|
|
|
appendCandidate(workspace.lastRememberedTerminalPanelForConfigInheritance())
|
|
for terminalPanel in panels.values
|
|
.compactMap({ $0 as? TerminalPanel })
|
|
.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {
|
|
appendCandidate(terminalPanel)
|
|
}
|
|
|
|
if let livePanel = candidates.first(where: { $0.surface.hasLiveSurface && $0.surface.surface != nil }) {
|
|
return livePanel
|
|
}
|
|
return candidates.first
|
|
}
|
|
|
|
private func inheritedTerminalConfigForNewWorkspace() -> CmuxSurfaceConfigTemplate? {
|
|
inheritedTerminalConfigForNewWorkspace(workspace: selectedWorkspace)
|
|
}
|
|
|
|
private func cachedInheritedTerminalFontPointsForNewWorkspace(
|
|
workspace: Workspace?
|
|
) -> Float? {
|
|
guard let workspace else { return nil }
|
|
// New workspace creation only seeds font size into a fresh Swift-owned template.
|
|
// Avoid reading live panel/surface state here; the arm64 Nightly Cmd+N crash path
|
|
// was repeatedly dereferencing pointer-backed terminal objects while preparing the
|
|
// new workspace. The workspace already caches the rooted font lineage we need.
|
|
return withExtendedLifetime(workspace) {
|
|
guard let fontPoints = workspace.lastRememberedTerminalFontPointsForConfigInheritance(),
|
|
fontPoints > 0 else {
|
|
return nil
|
|
}
|
|
return fontPoints
|
|
}
|
|
}
|
|
|
|
func inheritedTerminalConfigForNewWorkspace(
|
|
workspace: Workspace?
|
|
) -> CmuxSurfaceConfigTemplate? {
|
|
guard let fontPoints = cachedInheritedTerminalFontPointsForNewWorkspace(workspace: workspace) else {
|
|
return nil
|
|
}
|
|
var config = CmuxSurfaceConfigTemplate()
|
|
config.fontSize = fontPoints
|
|
return config
|
|
}
|
|
|
|
private func inheritedTerminalFontPointsForNewWorkspace() -> Float? {
|
|
inheritedTerminalFontPointsForNewWorkspace(workspace: selectedWorkspace)
|
|
}
|
|
|
|
private func inheritedTerminalFontPointsForNewWorkspace(
|
|
workspace: Workspace?
|
|
) -> Float? {
|
|
cachedInheritedTerminalFontPointsForNewWorkspace(workspace: workspace)
|
|
}
|
|
|
|
private func workspaceCreationConfigTemplate(
|
|
inheritedTerminalFontPoints: Float?
|
|
) -> CmuxSurfaceConfigTemplate? {
|
|
guard let inheritedTerminalFontPoints, inheritedTerminalFontPoints > 0 else {
|
|
return nil
|
|
}
|
|
// Rebuild a clean Swift-owned template instead of carrying over any pointer-backed
|
|
// inherited config state from the source workspace.
|
|
var config = CmuxSurfaceConfigTemplate()
|
|
config.fontSize = inheritedTerminalFontPoints
|
|
return config
|
|
}
|
|
|
|
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 {
|
|
newTabInsertIndex(snapshot: workspaceCreationSnapshot(), placementOverride: placementOverride)
|
|
}
|
|
|
|
private func newTabInsertIndex(
|
|
snapshot: WorkspaceCreationSnapshot,
|
|
placementOverride: NewWorkspacePlacement? = nil
|
|
) -> Int {
|
|
let placement = placementOverride ?? WorkspacePlacementSettings.current()
|
|
let liveTabs = orderedLiveWorkspaceCreationTabs(from: snapshot) ?? snapshot.tabs
|
|
let pinnedCount = liveTabs.reduce(into: 0) { partial, tab in
|
|
if tab.isPinned {
|
|
partial += 1
|
|
}
|
|
}
|
|
|
|
switch placement {
|
|
case .top:
|
|
return pinnedCount
|
|
case .end:
|
|
return liveTabs.count
|
|
case .afterCurrent:
|
|
if let selectedTabId = snapshot.selectedTabId,
|
|
let selectedIndex = liveTabs.firstIndex(where: { $0.id == selectedTabId }) {
|
|
return WorkspacePlacementSettings.insertionIndex(
|
|
placement: placement,
|
|
selectedIndex: selectedIndex,
|
|
selectedIsPinned: snapshot.selectedTabWasPinned,
|
|
pinnedCount: pinnedCount,
|
|
totalCount: liveTabs.count
|
|
)
|
|
}
|
|
return snapshot.selectedTabWasPinned ? pinnedCount : liveTabs.count
|
|
}
|
|
}
|
|
|
|
private func preferredWorkingDirectoryForNewTab() -> String? {
|
|
preferredWorkingDirectoryForNewTab(workspace: selectedWorkspace)
|
|
}
|
|
|
|
private func preferredWorkingDirectoryForNewTab(
|
|
workspace: Workspace?
|
|
) -> String? {
|
|
guard let workspace else {
|
|
return nil
|
|
}
|
|
// Use cached directory state only; avoiding live focus traversal keeps workspace
|
|
// creation resilient when Bonsplit is in the middle of a rapid Cmd+N churn.
|
|
if let currentDirectory = normalizedWorkingDirectory(workspace.currentDirectory) {
|
|
return currentDirectory
|
|
}
|
|
|
|
return workspace.panelDirectories.values.lazy.compactMap { directory in
|
|
self.normalizedWorkingDirectory(directory)
|
|
}.first
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func moveTabToTopForNotification(_ tabId: UUID) {
|
|
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
|
let pinnedCount = tabs.filter { $0.isPinned }.count
|
|
guard index != pinnedCount else { return }
|
|
let tab = tabs[index]
|
|
guard !tab.isPinned else { return }
|
|
tabs.remove(at: index)
|
|
tabs.insert(tab, at: pinnedCount)
|
|
}
|
|
|
|
@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 workspace = tabs[currentIndex]
|
|
let clamped = clampedReorderIndex(for: workspace, targetIndex: targetIndex)
|
|
if currentIndex == clamped { return true }
|
|
|
|
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)
|
|
}
|
|
|
|
private func clampedReorderIndex(for workspace: Workspace, targetIndex: Int) -> Int {
|
|
let clamped = max(0, min(targetIndex, tabs.count - 1))
|
|
let pinnedCount = tabs.filter { $0.isPinned }.count
|
|
if workspace.isPinned {
|
|
return min(clamped, max(0, pinnedCount - 1))
|
|
}
|
|
return max(clamped, pinnedCount)
|
|
}
|
|
|
|
// 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 previousDirectory = gitProbeDirectory(for: tab, panelId: surfaceId)
|
|
let normalized = normalizeDirectory(directory)
|
|
tab.updatePanelDirectory(panelId: surfaceId, directory: normalized)
|
|
let nextDirectory = normalizedWorkingDirectory(normalized)
|
|
if previousDirectory != nextDirectory {
|
|
scheduleWorkspaceGitMetadataRefreshIfPossible(
|
|
workspaceId: tabId,
|
|
panelId: surfaceId,
|
|
reason: "directoryChange"
|
|
)
|
|
}
|
|
}
|
|
|
|
func updateSurfaceGitBranch(
|
|
tabId: UUID,
|
|
surfaceId: UUID,
|
|
branch: String,
|
|
isDirty: Bool
|
|
) {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
|
let current = tab.panelGitBranches[surfaceId]
|
|
let normalizedBranch = Self.normalizedBranchName(branch) ?? branch
|
|
guard current?.branch != normalizedBranch || current?.isDirty != isDirty else { return }
|
|
tab.updatePanelGitBranch(panelId: surfaceId, branch: normalizedBranch, isDirty: isDirty)
|
|
scheduleWorkspaceGitMetadataRefreshIfPossible(
|
|
workspaceId: tabId,
|
|
panelId: surfaceId,
|
|
reason: "branchChange"
|
|
)
|
|
}
|
|
|
|
func clearSurfaceGitBranch(tabId: UUID, surfaceId: UUID) {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
|
let hadBranch = tab.panelGitBranches[surfaceId] != nil
|
|
let hadPullRequest = tab.panelPullRequests[surfaceId] != nil
|
|
guard hadBranch || hadPullRequest else { return }
|
|
tab.clearPanelGitBranch(panelId: surfaceId)
|
|
tab.clearPanelPullRequest(panelId: surfaceId)
|
|
scheduleWorkspaceGitMetadataRefreshIfPossible(
|
|
workspaceId: tabId,
|
|
panelId: surfaceId,
|
|
reason: "branchCleared"
|
|
)
|
|
}
|
|
|
|
func updateSurfaceShellActivity(
|
|
tabId: UUID,
|
|
surfaceId: UUID,
|
|
state: Workspace.PanelShellActivityState
|
|
) {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
|
tab.updatePanelShellActivityState(panelId: surfaceId, state: state)
|
|
}
|
|
|
|
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 }
|
|
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
|
|
clearWorkspaceGitProbes(workspaceId: workspace.id)
|
|
sidebarSelectedWorkspaceIds.remove(workspace.id)
|
|
|
|
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
|
|
workspace.teardownAllPanels()
|
|
workspace.teardownRemoteConnection()
|
|
unwireClosedBrowserTracking(for: workspace)
|
|
workspace.owningTabManager = nil
|
|
|
|
if let index = tabs.firstIndex(where: { $0.id == workspace.id }) {
|
|
tabs.remove(at: index)
|
|
|
|
if selectedTabId == workspace.id {
|
|
// Keep the "focused index" stable when possible:
|
|
// - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up).
|
|
// - Otherwise (we closed the last workspace), focus the new last workspace (i-1).
|
|
let newIndex = min(index, max(0, tabs.count - 1))
|
|
selectedTabId = tabs[newIndex].id
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Detach a workspace from this window without closing its panels.
|
|
/// Used by the socket API for cross-window moves.
|
|
@discardableResult
|
|
func detachWorkspace(tabId: UUID) -> Workspace? {
|
|
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
|
|
clearWorkspaceGitProbes(workspaceId: tabId)
|
|
sidebarSelectedWorkspaceIds.remove(tabId)
|
|
|
|
let removed = tabs.remove(at: index)
|
|
unwireClosedBrowserTracking(for: removed)
|
|
removed.owningTabManager = nil
|
|
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) {
|
|
workspace.owningTabManager = self
|
|
wireClosedBrowserTracking(for: workspace)
|
|
let insertIndex: Int = {
|
|
guard let index else { return tabs.count }
|
|
return max(0, min(index, tabs.count))
|
|
}()
|
|
tabs.insert(workspace, at: insertIndex)
|
|
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 = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)"
|
|
guard confirmClose(
|
|
title: "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
|
|
let sidebarSelectionIds = orderedSidebarSelectedWorkspaceIds()
|
|
if sidebarSelectionIds.count > 1 {
|
|
closeWorkspacesWithConfirmation(sidebarSelectionIds, allowPinned: true)
|
|
return
|
|
}
|
|
guard let selectedId = selectedTabId,
|
|
let workspace = tabs.first(where: { $0.id == selectedId }) else { return }
|
|
closeWorkspaceWithConfirmation(workspace)
|
|
}
|
|
|
|
func canCloseWorkspace(_ workspace: Workspace, allowPinned: Bool = false) -> Bool {
|
|
allowPinned || !workspace.isPinned
|
|
}
|
|
|
|
@discardableResult
|
|
func closeWorkspaceWithConfirmation(_ workspace: Workspace) -> Bool {
|
|
if workspace.isPinned {
|
|
guard confirmClose(
|
|
title: String(localized: "dialog.closePinnedWorkspace.title", defaultValue: "Close pinned workspace?"),
|
|
message: String(
|
|
localized: "dialog.closePinnedWorkspace.message",
|
|
defaultValue: "This workspace is pinned. Closing it will close the workspace and all of its panels."
|
|
),
|
|
acceptCmdD: tabs.count <= 1
|
|
) else {
|
|
return false
|
|
}
|
|
closeWorkspaceIfRunningProcess(workspace, requiresConfirmation: false)
|
|
return true
|
|
}
|
|
closeWorkspaceIfRunningProcess(workspace)
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func closeWorkspaceWithConfirmation(tabId: UUID) -> Bool {
|
|
guard let workspace = tabs.first(where: { $0.id == tabId }) else { return false }
|
|
return closeWorkspaceWithConfirmation(workspace)
|
|
}
|
|
|
|
func setSidebarSelectedWorkspaceIds(_ workspaceIds: Set<UUID>) {
|
|
let existingIds = Set(tabs.map(\.id))
|
|
sidebarSelectedWorkspaceIds = workspaceIds.intersection(existingIds)
|
|
}
|
|
|
|
func closeWorkspacesWithConfirmation(_ workspaceIds: [UUID], allowPinned: Bool) {
|
|
let workspaces = orderedClosableWorkspaces(workspaceIds, allowPinned: allowPinned)
|
|
guard !workspaces.isEmpty else { return }
|
|
guard workspaces.count > 1 else {
|
|
closeWorkspaceWithConfirmation(workspaces[0])
|
|
return
|
|
}
|
|
|
|
let plan = closeWorkspacesPlan(for: workspaces)
|
|
guard confirmClose(
|
|
title: plan.title,
|
|
message: plan.message,
|
|
acceptCmdD: plan.acceptCmdD
|
|
) else { return }
|
|
|
|
for workspace in plan.workspaces {
|
|
guard tabs.contains(where: { $0.id == workspace.id }) else { continue }
|
|
closeWorkspaceIfRunningProcess(workspace, requiresConfirmation: false)
|
|
}
|
|
}
|
|
|
|
func selectWorkspace(_ workspace: Workspace) {
|
|
#if DEBUG
|
|
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
|
|
#endif
|
|
selectedTabId = workspace.id
|
|
}
|
|
|
|
// Keep selectTab as convenience alias
|
|
func selectTab(_ tab: Workspace) { selectWorkspace(tab) }
|
|
|
|
private func confirmClose(title: String, message: String, acceptCmdD: Bool) -> Bool {
|
|
if let confirmCloseHandler {
|
|
return confirmCloseHandler(title, message, acceptCmdD)
|
|
}
|
|
_ = acceptCmdD
|
|
|
|
let alert = NSAlert()
|
|
alert.messageText = title
|
|
alert.informativeText = message
|
|
alert.alertStyle = .warning
|
|
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
|
|
alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel"))
|
|
|
|
if let closeButton = alert.buttons.first {
|
|
closeButton.keyEquivalent = "\r"
|
|
closeButton.keyEquivalentModifierMask = []
|
|
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
|
|
alert.window.initialFirstResponder = closeButton
|
|
}
|
|
if let cancelButton = alert.buttons.dropFirst().first {
|
|
cancelButton.keyEquivalent = "\u{1b}"
|
|
}
|
|
|
|
if NSApp.activationPolicy() == .regular {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
return alert.runModal() == .alertFirstButtonReturn
|
|
}
|
|
|
|
private struct CloseOtherTabsInFocusedPanePlan {
|
|
let workspace: Workspace
|
|
let panelIds: [UUID]
|
|
let titles: [String]
|
|
}
|
|
|
|
private struct CloseWorkspacesPlan {
|
|
let workspaces: [Workspace]
|
|
let title: String
|
|
let message: String
|
|
let acceptCmdD: Bool
|
|
}
|
|
|
|
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 "Untitled Tab"
|
|
}
|
|
|
|
private func orderedClosableWorkspaces(_ workspaceIds: [UUID], allowPinned: Bool) -> [Workspace] {
|
|
let targetIds = Set(workspaceIds)
|
|
return tabs.compactMap { workspace in
|
|
guard targetIds.contains(workspace.id) else { return nil }
|
|
guard allowPinned || !workspace.isPinned else { return nil }
|
|
return workspace
|
|
}
|
|
}
|
|
|
|
private func orderedSidebarSelectedWorkspaceIds() -> [UUID] {
|
|
tabs.compactMap { workspace in
|
|
sidebarSelectedWorkspaceIds.contains(workspace.id) ? workspace.id : nil
|
|
}
|
|
}
|
|
|
|
private func closeWorkspacesPlan(for workspaces: [Workspace]) -> CloseWorkspacesPlan {
|
|
let willCloseWindow = workspaces.count == tabs.count
|
|
let title = willCloseWindow
|
|
? String(localized: "dialog.closeWindow.title", defaultValue: "Close window?")
|
|
: String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
|
|
let titleLines = workspaces
|
|
.map { "• \(closeWorkspaceDisplayTitle($0.title))" }
|
|
.joined(separator: "\n")
|
|
let format = willCloseWindow
|
|
? String(
|
|
localized: "dialog.closeWorkspacesWindow.message",
|
|
defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@"
|
|
)
|
|
: String(
|
|
localized: "dialog.closeWorkspaces.message",
|
|
defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
|
|
)
|
|
let message = String(format: format, locale: .current, Int64(workspaces.count), titleLines)
|
|
return CloseWorkspacesPlan(
|
|
workspaces: workspaces,
|
|
title: title,
|
|
message: message,
|
|
acceptCmdD: willCloseWindow
|
|
)
|
|
}
|
|
|
|
private func closeWorkspaceDisplayTitle(_ title: String?) -> String {
|
|
let collapsed = title?
|
|
.replacingOccurrences(of: "\n", with: " ")
|
|
.replacingOccurrences(of: "\r", with: " ")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let collapsed, !collapsed.isEmpty {
|
|
return collapsed
|
|
}
|
|
return String(localized: "workspace.displayName.fallback", defaultValue: "Workspace")
|
|
}
|
|
|
|
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace, requiresConfirmation: Bool = true) {
|
|
let willCloseWindow = tabs.count <= 1
|
|
if requiresConfirmation,
|
|
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).
|
|
if let window {
|
|
window.performClose(nil)
|
|
} else {
|
|
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
|
|
}
|
|
} else {
|
|
closeWorkspace(workspace)
|
|
}
|
|
}
|
|
|
|
private func shouldCloseWorkspaceOnLastSurfaceShortcut(_ workspace: Workspace, panelId: UUID) -> Bool {
|
|
LastSurfaceCloseShortcutSettings.closesWorkspace() &&
|
|
workspace.panels.count <= 1 &&
|
|
workspace.panels[panelId] != nil
|
|
}
|
|
|
|
private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) {
|
|
guard tab.panels[panelId] != nil else {
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.close.shortcut.skip tab=\(tab.id.uuidString.prefix(5)) " +
|
|
"panel=\(panelId.uuidString.prefix(5)) reason=missingPanel"
|
|
)
|
|
#endif
|
|
return
|
|
}
|
|
|
|
let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in
|
|
partial + tab.bonsplitController.tabs(inPane: paneId).count
|
|
}
|
|
let panelKind: String = {
|
|
guard let panel = tab.panels[panelId] else { return "missing" }
|
|
if panel is TerminalPanel { return "terminal" }
|
|
if panel is BrowserPanel { return "browser" }
|
|
return String(describing: type(of: panel))
|
|
}()
|
|
let closesWorkspaceOnLastSurfaceShortcut = shouldCloseWorkspaceOnLastSurfaceShortcut(tab, panelId: panelId)
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " +
|
|
"panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " +
|
|
"panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount) " +
|
|
"closeWorkspaceOnLastSurface=\(closesWorkspaceOnLastSurfaceShortcut ? 1 : 0)"
|
|
)
|
|
#endif
|
|
|
|
// The last-surface shortcut preference only affects Cmd+W. The tab close button
|
|
// continues to use Workspace's explicit-close path when it closes the last surface.
|
|
if closesWorkspaceOnLastSurfaceShortcut,
|
|
let surfaceId = tab.surfaceIdFromPanelId(panelId) {
|
|
tab.markExplicitClose(surfaceId: surfaceId)
|
|
}
|
|
let closed = tab.closePanel(panelId)
|
|
#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),
|
|
tab.panelNeedsConfirmClose(panelId: surfaceId, fallbackNeedsConfirmClose: 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 }
|
|
let keepsRemoteWorkspaceOpen =
|
|
tab.panels.count <= 1 && tab.shouldDemoteWorkspaceAfterChildExit(surfaceId: surfaceId)
|
|
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " +
|
|
"surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count) " +
|
|
"remoteWorkspace=\(tab.isRemoteWorkspace ? 1 : 0) keepRemote=\(keepsRemoteWorkspaceOpen ? 1 : 0)"
|
|
)
|
|
#endif
|
|
|
|
// Exiting the last SSH surface should demote the workspace back to a local one.
|
|
// Route through Workspace close handling so remote teardown and replacement-panel
|
|
// logic run before TabManager considers removing the workspace itself, including
|
|
// session-end paths where remote configuration was cleared before Ghostty delivered
|
|
// the child-exit callback.
|
|
if keepsRemoteWorkspaceOpen {
|
|
closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId)
|
|
return
|
|
}
|
|
|
|
// Child-exit on the last panel should collapse the workspace, matching explicit close
|
|
// semantics (and close the window when it was the last workspace).
|
|
if tab.panels.count <= 1 {
|
|
if tabs.count <= 1 {
|
|
if let app = AppDelegate.shared {
|
|
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
|
|
}
|
|
|
|
func toggleReactGrabFocusedBrowser() {
|
|
guard let panel = focusedBrowserPanel else { return }
|
|
Task { await panel.toggleOrInjectReactGrab() }
|
|
}
|
|
|
|
/// 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 }
|
|
|
|
let panelId: UUID
|
|
if let restoredPanelId = lastFocusedPanelByTab[selectedTabId],
|
|
tab.panels[restoredPanelId] != nil {
|
|
panelId = restoredPanelId
|
|
} else if let focusedPanelId = tab.focusedPanelId,
|
|
tab.panels[focusedPanelId] != nil {
|
|
panelId = focusedPanelId
|
|
} else {
|
|
return
|
|
}
|
|
|
|
// 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)
|
|
)
|
|
}
|
|
|
|
// Route workspace reactivation through the normal focus machinery so panel-local
|
|
// activation intents like browser find-field focus are restored on return.
|
|
tab.focusPanel(panelId)
|
|
}
|
|
|
|
func completePendingWorkspaceUnfocus(reason: String) {
|
|
guard let pending = pendingWorkspaceUnfocusTarget else { return }
|
|
// If this tab became selected again before handoff completion, drop the stale
|
|
// pending entry so it cannot be flushed later and deactivate the selected workspace.
|
|
guard Self.shouldUnfocusPendingWorkspace(
|
|
pendingTabId: pending.tabId,
|
|
selectedTabId: selectedTabId
|
|
) else {
|
|
pendingWorkspaceUnfocusTarget = nil
|
|
#if DEBUG
|
|
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 dismissFocusedPanelNotificationIfActive(tabId: UUID) {
|
|
let shouldSuppressFlash = suppressFocusFlash
|
|
suppressFocusFlash = false
|
|
guard !shouldSuppressFlash else { return }
|
|
guard AppFocusState.isAppActive() else { return }
|
|
guard let panelId = focusedPanelId(for: tabId) else { return }
|
|
dismissPanelNotificationOnFocusIfActive(tabId: tabId, panelId: panelId)
|
|
}
|
|
|
|
private func dismissPanelNotificationOnFocusIfActive(tabId: UUID, panelId: UUID) {
|
|
guard selectedTabId == tabId else { return }
|
|
guard !suppressFocusFlash else { return }
|
|
guard AppFocusState.isAppActive() else { return }
|
|
_ = dismissNotificationOnDirectInteraction(tabId: tabId, surfaceId: panelId)
|
|
}
|
|
|
|
@discardableResult
|
|
func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool {
|
|
guard selectedTabId == tabId else { return false }
|
|
guard AppFocusState.isAppActive() else { return false }
|
|
guard let notificationStore = AppDelegate.shared?.notificationStore else { return false }
|
|
let hasUnreadNotification = notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId)
|
|
let hasFocusedIndicator = notificationStore.hasVisibleNotificationIndicator(forTabId: tabId, surfaceId: surfaceId)
|
|
guard hasUnreadNotification || hasFocusedIndicator else { return false }
|
|
if hasUnreadNotification {
|
|
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
|
|
}
|
|
notificationStore.clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId)
|
|
if let panelId = surfaceId,
|
|
let tab = tabs.first(where: { $0.id == tabId }) {
|
|
tab.triggerNotificationDismissFlash(panelId: panelId)
|
|
}
|
|
return true
|
|
}
|
|
|
|
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
|
|
}
|
|
#if DEBUG
|
|
debugPrimeWorkspaceSwitchTrigger("focus", to: tabId)
|
|
#endif
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) -> Bool {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else {
|
|
#if DEBUG
|
|
dlog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) reason=missingTab")
|
|
#endif
|
|
return false
|
|
}
|
|
if let surfaceId, tab.panels[surfaceId] == nil {
|
|
#if DEBUG
|
|
dlog(
|
|
"notification.focus.fail tab=\(tabId.uuidString.prefix(5)) " +
|
|
"panel=\(surfaceId.uuidString.prefix(5)) reason=missingPanel"
|
|
)
|
|
#endif
|
|
return false
|
|
}
|
|
let desiredPanelId = surfaceId ?? tab.focusedPanelId
|
|
#if DEBUG
|
|
if let desiredPanelId {
|
|
AppDelegate.shared?.armJumpUnreadFocusRecord(tabId: tabId, surfaceId: desiredPanelId)
|
|
}
|
|
#endif
|
|
// Jump-to-unread should reveal the destination pane instead of keeping an old split-zoom
|
|
// state active around it.
|
|
tab.clearSplitZoom()
|
|
suppressFocusFlash = true
|
|
focusTab(tabId, surfaceId: desiredPanelId, suppressFlash: true)
|
|
suppressFocusFlash = false
|
|
|
|
if let targetPanelId = desiredPanelId ?? tab.focusedPanelId,
|
|
tab.panels[targetPanelId] != nil {
|
|
_ = dismissNotificationOnDirectInteraction(tabId: tabId, surfaceId: targetPanelId)
|
|
}
|
|
return true
|
|
}
|
|
|
|
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
|
|
debugPrepareWorkspaceSwitch("next", from: currentId, to: nextId)
|
|
#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
|
|
debugPrepareWorkspaceSwitch("prev", from: currentId, to: prevId)
|
|
#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 func debugPrimeWorkspaceSwitchTrigger(_ trigger: String, to target: UUID?) {
|
|
guard selectedTabId != target else {
|
|
debugPendingWorkspaceSwitchTrigger = nil
|
|
debugPendingWorkspaceSwitchTarget = nil
|
|
return
|
|
}
|
|
debugPendingWorkspaceSwitchTrigger = trigger
|
|
debugPendingWorkspaceSwitchTarget = target
|
|
}
|
|
|
|
private func debugPrepareWorkspaceSwitch(_ trigger: String, from: UUID?, to: UUID?) {
|
|
guard from != to else {
|
|
debugPendingWorkspaceSwitchTrigger = nil
|
|
debugPendingWorkspaceSwitchTarget = nil
|
|
debugPreparedWorkspaceSwitchTarget = nil
|
|
return
|
|
}
|
|
debugPendingWorkspaceSwitchTrigger = nil
|
|
debugPendingWorkspaceSwitchTarget = nil
|
|
debugBeginWorkspaceSwitch(trigger: trigger, from: from, to: to)
|
|
debugPreparedWorkspaceSwitchTarget = to
|
|
}
|
|
|
|
private func debugBeginWorkspaceSwitch(trigger: String, from: UUID?, to: UUID?) {
|
|
debugWorkspaceSwitchCounter &+= 1
|
|
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
|
|
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
|
|
dlog(
|
|
"ws.switch.begin id=\(debugWorkspaceSwitchId) trigger=\(trigger) " +
|
|
"from=\(Self.debugShortWorkspaceId(from)) to=\(Self.debugShortWorkspaceId(to)) " +
|
|
"hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
|
|
)
|
|
}
|
|
|
|
private static func debugShortWorkspaceId(_ id: UUID?) -> String {
|
|
guard let id else { return "nil" }
|
|
return String(id.uuidString.prefix(5))
|
|
}
|
|
|
|
private static func debugMsText(_ ms: Double) -> String {
|
|
String(format: "%.2fms", ms)
|
|
}
|
|
#endif
|
|
|
|
func selectTab(at index: Int) {
|
|
guard index >= 0 && index < tabs.count else { return }
|
|
#if DEBUG
|
|
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabs[index].id)
|
|
#endif
|
|
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
|
|
@discardableResult
|
|
func createSplit(direction: SplitDirection) -> UUID? {
|
|
guard let selectedTabId,
|
|
let tab = tabs.first(where: { $0.id == selectedTabId }),
|
|
let focusedPanelId = tab.focusedPanelId else { return nil }
|
|
return createSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
|
|
}
|
|
|
|
/// Create a new split from an explicit source panel.
|
|
@discardableResult
|
|
func createSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }),
|
|
tab.panels[surfaceId] != nil else { return nil }
|
|
tab.clearSplitZoom()
|
|
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
|
|
return newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus)
|
|
}
|
|
|
|
/// Create a new browser split from the currently focused panel.
|
|
@discardableResult
|
|
func createBrowserSplit(direction: SplitDirection, url: URL? = nil) -> UUID? {
|
|
guard let selectedTabId,
|
|
let tab = tabs.first(where: { $0.id == selectedTabId }),
|
|
let focusedPanelId = tab.focusedPanelId else { return nil }
|
|
tab.clearSplitZoom()
|
|
return newBrowserSplit(
|
|
tabId: selectedTabId,
|
|
fromPanelId: focusedPanelId,
|
|
orientation: direction.orientation,
|
|
insertFirst: direction.insertFirst,
|
|
url: url
|
|
)
|
|
}
|
|
|
|
/// Refresh Bonsplit right-side action button tooltips for all workspaces.
|
|
func refreshSplitButtonTooltips() {
|
|
for workspace in tabs {
|
|
workspace.refreshSplitButtonTooltips()
|
|
}
|
|
}
|
|
|
|
// MARK: - Pane Focus Navigation
|
|
|
|
/// Move focus to an adjacent pane in the specified direction
|
|
func movePaneFocus(direction: NavigationDirection) {
|
|
guard let selectedTabId,
|
|
let tab = tabs.first(where: { $0.id == selectedTabId }) else { return }
|
|
tab.moveFocus(direction: direction)
|
|
}
|
|
|
|
// MARK: - Recent Tab History Navigation
|
|
|
|
private func recordTabInHistory(_ tabId: UUID) {
|
|
// If we're not at the end of history, truncate forward history
|
|
if historyIndex < tabHistory.count - 1 {
|
|
tabHistory = Array(tabHistory.prefix(historyIndex + 1))
|
|
}
|
|
|
|
// Don't add duplicate consecutive entries
|
|
if tabHistory.last == tabId {
|
|
return
|
|
}
|
|
|
|
tabHistory.append(tabId)
|
|
|
|
// Trim history if it exceeds max size
|
|
if tabHistory.count > maxHistorySize {
|
|
tabHistory.removeFirst(tabHistory.count - maxHistorySize)
|
|
}
|
|
|
|
historyIndex = tabHistory.count - 1
|
|
}
|
|
|
|
func navigateBack() {
|
|
guard historyIndex > 0 else { return }
|
|
|
|
// Find the previous valid tab in history (skip closed tabs)
|
|
var targetIndex = historyIndex - 1
|
|
while targetIndex >= 0 {
|
|
let tabId = tabHistory[targetIndex]
|
|
if tabs.contains(where: { $0.id == tabId }) {
|
|
isNavigatingHistory = true
|
|
historyIndex = targetIndex
|
|
selectedTabId = tabId
|
|
isNavigatingHistory = false
|
|
return
|
|
}
|
|
// Remove closed tab from history
|
|
tabHistory.remove(at: targetIndex)
|
|
historyIndex -= 1
|
|
targetIndex -= 1
|
|
}
|
|
}
|
|
|
|
func navigateForward() {
|
|
guard historyIndex < tabHistory.count - 1 else { return }
|
|
|
|
// Find the next valid tab in history (skip closed tabs)
|
|
let targetIndex = historyIndex + 1
|
|
while targetIndex < tabHistory.count {
|
|
let tabId = tabHistory[targetIndex]
|
|
if tabs.contains(where: { $0.id == tabId }) {
|
|
isNavigatingHistory = true
|
|
historyIndex = targetIndex
|
|
selectedTabId = tabId
|
|
isNavigatingHistory = false
|
|
return
|
|
}
|
|
// Remove closed tab from history
|
|
tabHistory.remove(at: targetIndex)
|
|
// Don't increment targetIndex since we removed the element
|
|
}
|
|
}
|
|
|
|
var canNavigateBack: Bool {
|
|
historyIndex > 0 && tabHistory.prefix(historyIndex).contains { tabId in
|
|
tabs.contains { $0.id == tabId }
|
|
}
|
|
}
|
|
|
|
var canNavigateForward: Bool {
|
|
historyIndex < tabHistory.count - 1 && tabHistory.suffix(from: historyIndex + 1).contains { tabId in
|
|
tabs.contains { $0.id == tabId }
|
|
}
|
|
}
|
|
|
|
// MARK: - Split Operations (Backwards Compatibility)
|
|
|
|
/// Create a new split in the specified direction
|
|
/// Returns the new panel's ID (which is also the surface ID for terminals)
|
|
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
|
return tab.newTerminalSplit(
|
|
from: surfaceId,
|
|
orientation: direction.orientation,
|
|
insertFirst: direction.insertFirst,
|
|
focus: focus
|
|
)?.id
|
|
}
|
|
|
|
/// Move focus in the specified direction
|
|
func moveSplitFocus(tabId: UUID, surfaceId: UUID, direction: NavigationDirection) -> Bool {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return false }
|
|
tab.moveFocus(direction: direction)
|
|
return true
|
|
}
|
|
|
|
/// Resize split - not directly supported by bonsplit, but we can adjust divider positions
|
|
func resizeSplit(tabId: UUID, surfaceId: UUID, direction: ResizeDirection, amount: UInt16) -> Bool {
|
|
guard amount > 0,
|
|
let tab = tabs.first(where: { $0.id == tabId }),
|
|
let paneId = tab.paneId(forPanelId: surfaceId) else { return false }
|
|
|
|
let paneUUID = paneId.id
|
|
guard tab.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else {
|
|
return false
|
|
}
|
|
|
|
var candidates: [ResizeSplitCandidate] = []
|
|
let trace = resizeSplitCollectCandidates(
|
|
node: tab.bonsplitController.treeSnapshot(),
|
|
targetPaneId: paneUUID.uuidString,
|
|
candidates: &candidates
|
|
)
|
|
guard trace.containsTarget else { return false }
|
|
|
|
let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation }
|
|
guard !orientationMatches.isEmpty else { return false }
|
|
|
|
guard let candidate = orientationMatches.first(where: {
|
|
$0.paneInFirstChild == direction.requiresPaneInFirstChild
|
|
}) else {
|
|
return false
|
|
}
|
|
|
|
let delta = CGFloat(amount) / candidate.axisPixels
|
|
let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta)
|
|
let clamped = min(max(requested, 0.1), 0.9)
|
|
return tab.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true)
|
|
}
|
|
|
|
/// 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
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct ResizeSplitCandidate {
|
|
let splitId: UUID
|
|
let orientation: String
|
|
let paneInFirstChild: Bool
|
|
let dividerPosition: CGFloat
|
|
let axisPixels: CGFloat
|
|
}
|
|
|
|
private struct ResizeSplitTrace {
|
|
let containsTarget: Bool
|
|
let bounds: CGRect
|
|
}
|
|
|
|
private func resizeSplitCollectCandidates(
|
|
node: ExternalTreeNode,
|
|
targetPaneId: String,
|
|
candidates: inout [ResizeSplitCandidate]
|
|
) -> ResizeSplitTrace {
|
|
switch node {
|
|
case .pane(let pane):
|
|
let bounds = CGRect(
|
|
x: pane.frame.x,
|
|
y: pane.frame.y,
|
|
width: pane.frame.width,
|
|
height: pane.frame.height
|
|
)
|
|
return ResizeSplitTrace(containsTarget: pane.id == targetPaneId, bounds: bounds)
|
|
|
|
case .split(let split):
|
|
let first = resizeSplitCollectCandidates(
|
|
node: split.first,
|
|
targetPaneId: targetPaneId,
|
|
candidates: &candidates
|
|
)
|
|
let second = resizeSplitCollectCandidates(
|
|
node: split.second,
|
|
targetPaneId: targetPaneId,
|
|
candidates: &candidates
|
|
)
|
|
|
|
let combinedBounds = first.bounds.union(second.bounds)
|
|
let containsTarget = first.containsTarget || second.containsTarget
|
|
|
|
if containsTarget,
|
|
let splitUUID = UUID(uuidString: split.id) {
|
|
let orientation = split.orientation.lowercased()
|
|
let axisPixels: CGFloat = orientation == "horizontal"
|
|
? combinedBounds.width
|
|
: combinedBounds.height
|
|
candidates.append(ResizeSplitCandidate(
|
|
splitId: splitUUID,
|
|
orientation: orientation,
|
|
paneInFirstChild: first.containsTarget,
|
|
dividerPosition: CGFloat(split.dividerPosition),
|
|
axisPixels: max(axisPixels, 1)
|
|
))
|
|
}
|
|
|
|
return ResizeSplitTrace(containsTarget: containsTarget, bounds: combinedBounds)
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
preferredProfileID: UUID? = 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,
|
|
preferredProfileID: preferredProfileID,
|
|
focus: focus
|
|
)?.id
|
|
}
|
|
|
|
/// Create a new browser surface in a pane
|
|
func newBrowserSurface(
|
|
tabId: UUID,
|
|
inPane paneId: PaneID,
|
|
url: URL? = nil,
|
|
preferredProfileID: UUID? = nil
|
|
) -> UUID? {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
|
return tab.newBrowserSurface(
|
|
inPane: paneId,
|
|
url: url,
|
|
preferredProfileID: preferredProfileID
|
|
)?.id
|
|
}
|
|
|
|
/// Get a browser panel by ID
|
|
func browserPanel(tabId: UUID, panelId: UUID) -> BrowserPanel? {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
|
return tab.browserPanel(for: panelId)
|
|
}
|
|
|
|
/// Open a browser in a specific workspace, optionally preferring a split-right layout.
|
|
@discardableResult
|
|
func openBrowser(
|
|
inWorkspace tabId: UUID,
|
|
url: URL? = nil,
|
|
preferSplitRight: Bool = false,
|
|
preferredProfileID: UUID? = nil,
|
|
insertAtEnd: Bool = false
|
|
) -> UUID? {
|
|
guard 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,
|
|
preferredProfileID: preferredProfileID
|
|
) {
|
|
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
|
|
return browserPanel.id
|
|
}
|
|
|
|
let splitSourcePanelId: UUID? = {
|
|
if let focusedPanelId = workspace.focusedPanelId,
|
|
workspace.panels[focusedPanelId] != nil {
|
|
return focusedPanelId
|
|
}
|
|
if let rememberedPanelId = lastFocusedPanelByTab[tabId],
|
|
workspace.panels[rememberedPanelId] != nil {
|
|
return rememberedPanelId
|
|
}
|
|
if let orderedPanelId = workspace.sidebarOrderedPanelIds().first(where: { workspace.panels[$0] != nil }) {
|
|
return orderedPanelId
|
|
}
|
|
return workspace.panels.keys.sorted { $0.uuidString < $1.uuidString }.first
|
|
}()
|
|
|
|
if let splitSourcePanelId,
|
|
let browserPanel = workspace.newBrowserSplit(
|
|
from: splitSourcePanelId,
|
|
orientation: .horizontal,
|
|
url: url,
|
|
preferredProfileID: preferredProfileID,
|
|
focus: true
|
|
) {
|
|
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
|
|
return browserPanel.id
|
|
}
|
|
}
|
|
|
|
guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first,
|
|
let browserPanel = workspace.newBrowserSurface(
|
|
inPane: paneId,
|
|
url: url,
|
|
focus: true,
|
|
insertAtEnd: insertAtEnd,
|
|
preferredProfileID: preferredProfileID
|
|
) else {
|
|
return nil
|
|
}
|
|
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
|
|
return browserPanel.id
|
|
}
|
|
|
|
/// Open a browser in the currently focused pane (as a new surface)
|
|
@discardableResult
|
|
func openBrowser(
|
|
url: URL? = nil,
|
|
preferredProfileID: UUID? = nil,
|
|
insertAtEnd: Bool = false
|
|
) -> UUID? {
|
|
guard let tabId = selectedTabId else { return nil }
|
|
return openBrowser(
|
|
inWorkspace: tabId,
|
|
url: url,
|
|
preferSplitRight: false,
|
|
preferredProfileID: preferredProfileID,
|
|
insertAtEnd: insertAtEnd
|
|
)
|
|
}
|
|
|
|
/// Reopen the most recently closed 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,
|
|
preferredProfileID: snapshot.profileID
|
|
) {
|
|
let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count
|
|
let maxIndex = max(0, tabCount - 1)
|
|
let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex)
|
|
_ = workspace.reorderSurface(panelId: browserPanel.id, toIndex: targetIndex)
|
|
return browserPanel.id
|
|
}
|
|
|
|
if let orientation = snapshot.fallbackSplitOrientation,
|
|
let fallbackAnchorPaneId = snapshot.fallbackAnchorPaneId,
|
|
let anchorPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == fallbackAnchorPaneId }),
|
|
let anchorTab = workspace.bonsplitController.selectedTab(inPane: anchorPane) ?? workspace.bonsplitController.tabs(inPane: anchorPane).first,
|
|
let anchorPanelId = workspace.panelIdFromSurfaceId(anchorTab.id),
|
|
let browserPanelId = workspace.newBrowserSplit(
|
|
from: anchorPanelId,
|
|
orientation: orientation,
|
|
insertFirst: snapshot.fallbackSplitInsertFirst,
|
|
url: snapshot.url,
|
|
preferredProfileID: snapshot.profileID
|
|
)?.id {
|
|
return browserPanelId
|
|
}
|
|
|
|
guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else {
|
|
return nil
|
|
}
|
|
return workspace.newBrowserSurface(
|
|
inPane: focusedPane,
|
|
url: snapshot.url,
|
|
focus: true,
|
|
preferredProfileID: snapshot.profileID
|
|
)?.id
|
|
}
|
|
|
|
/// Flash the currently focused panel so the user can visually confirm focus.
|
|
func triggerFocusFlash() {
|
|
guard let tab = selectedWorkspace,
|
|
let panelId = tab.focusedPanelId else { return }
|
|
tab.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 waitForWorkspacePanelsCondition(
|
|
tab: Workspace,
|
|
timeoutSeconds: TimeInterval,
|
|
condition: @escaping (Workspace) -> Bool
|
|
) async -> Bool {
|
|
guard !condition(tab) else { return true }
|
|
|
|
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
var resolved = false
|
|
var cancellable: AnyCancellable?
|
|
|
|
func finish(_ value: Bool) {
|
|
guard !resolved else { return }
|
|
resolved = true
|
|
cancellable?.cancel()
|
|
cont.resume(returning: value)
|
|
}
|
|
|
|
func evaluate() {
|
|
if condition(tab) {
|
|
finish(true)
|
|
}
|
|
}
|
|
|
|
cancellable = tab.$panels
|
|
.map { _ in () }
|
|
.sink { _ in evaluate() }
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
|
|
Task { @MainActor in
|
|
finish(condition(tab))
|
|
}
|
|
}
|
|
evaluate()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func waitForTerminalPanelCondition(
|
|
tab: Workspace,
|
|
panelId: UUID,
|
|
timeoutSeconds: TimeInterval,
|
|
condition: @escaping (TerminalPanel) -> Bool
|
|
) async -> Bool {
|
|
if let panel = tab.terminalPanel(for: panelId), condition(panel) {
|
|
return true
|
|
}
|
|
|
|
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
var resolved = false
|
|
var panelsCancellable: AnyCancellable?
|
|
var readyObserver: NSObjectProtocol?
|
|
var hostedViewObserver: NSObjectProtocol?
|
|
|
|
@MainActor
|
|
func finish(_ value: Bool) {
|
|
guard !resolved else { return }
|
|
resolved = true
|
|
panelsCancellable?.cancel()
|
|
if let readyObserver {
|
|
NotificationCenter.default.removeObserver(readyObserver)
|
|
}
|
|
if let hostedViewObserver {
|
|
NotificationCenter.default.removeObserver(hostedViewObserver)
|
|
}
|
|
cont.resume(returning: value)
|
|
}
|
|
|
|
@MainActor
|
|
func evaluate() {
|
|
guard let panel = tab.terminalPanel(for: panelId) else {
|
|
finish(false)
|
|
return
|
|
}
|
|
panel.surface.requestBackgroundSurfaceStartIfNeeded()
|
|
if condition(panel) {
|
|
finish(true)
|
|
}
|
|
}
|
|
|
|
panelsCancellable = tab.$panels
|
|
.map { _ in () }
|
|
.sink { _ in
|
|
Task { @MainActor in
|
|
evaluate()
|
|
}
|
|
}
|
|
readyObserver = NotificationCenter.default.addObserver(
|
|
forName: .terminalSurfaceDidBecomeReady,
|
|
object: nil,
|
|
queue: .main
|
|
) { note in
|
|
guard let readySurfaceId = note.userInfo?["surfaceId"] as? UUID,
|
|
readySurfaceId == panelId else { return }
|
|
Task { @MainActor in
|
|
evaluate()
|
|
}
|
|
}
|
|
hostedViewObserver = NotificationCenter.default.addObserver(
|
|
forName: .terminalSurfaceHostedViewDidMoveToWindow,
|
|
object: nil,
|
|
queue: .main
|
|
) { note in
|
|
guard let hostedSurfaceId = note.userInfo?["surfaceId"] as? UUID,
|
|
hostedSurfaceId == panelId else { return }
|
|
Task { @MainActor in
|
|
evaluate()
|
|
}
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
|
|
Task { @MainActor in
|
|
if let panel = tab.terminalPanel(for: panelId) {
|
|
finish(condition(panel))
|
|
} else {
|
|
finish(false)
|
|
}
|
|
}
|
|
}
|
|
evaluate()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func waitForTerminalPanelReadyForUITest(
|
|
tab: Workspace,
|
|
panelId: UUID,
|
|
timeoutSeconds: TimeInterval = 6.0
|
|
) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) {
|
|
var attached = false
|
|
var hasSurface = false
|
|
var firstResponder = false
|
|
|
|
let _ = await waitForTerminalPanelCondition(
|
|
tab: tab,
|
|
panelId: panelId,
|
|
timeoutSeconds: timeoutSeconds
|
|
) { panel in
|
|
panel.surface.requestBackgroundSurfaceStartIfNeeded()
|
|
attached = panel.hostedView.window != nil
|
|
hasSurface = panel.surface.surface != nil
|
|
firstResponder = panel.hostedView.isSurfaceViewFirstResponder()
|
|
return attached && hasSurface
|
|
}
|
|
|
|
return (attached, hasSurface, firstResponder)
|
|
}
|
|
|
|
private func setupUITestFocusShortcutsIfNeeded() {
|
|
guard !didSetupUITestFocusShortcuts else { return }
|
|
didSetupUITestFocusShortcuts = true
|
|
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_FOCUS_SHORTCUTS"] == "1" else { return }
|
|
|
|
// UI tests can't record arrow keys via the shortcut recorder. Use letter-based shortcuts
|
|
// so tests can reliably drive pane navigation without mouse clicks.
|
|
KeyboardShortcutSettings.setShortcut(
|
|
StoredShortcut(key: "h", command: true, shift: false, option: false, control: true),
|
|
for: .focusLeft
|
|
)
|
|
KeyboardShortcutSettings.setShortcut(
|
|
StoredShortcut(key: "l", command: true, shift: false, option: false, control: true),
|
|
for: .focusRight
|
|
)
|
|
KeyboardShortcutSettings.setShortcut(
|
|
StoredShortcut(key: "k", command: true, shift: false, option: false, control: true),
|
|
for: .focusUp
|
|
)
|
|
KeyboardShortcutSettings.setShortcut(
|
|
StoredShortcut(key: "j", command: true, shift: false, option: false, control: true),
|
|
for: .focusDown
|
|
)
|
|
}
|
|
|
|
private func setupSplitCloseRightUITestIfNeeded() {
|
|
guard !didSetupSplitCloseRightUITest else { return }
|
|
didSetupSplitCloseRightUITest = true
|
|
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] == "1" else { return }
|
|
guard let path = env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"], !path.isEmpty else { return }
|
|
let visualMode = env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] == "1"
|
|
let shotsDir = (env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SHOTS_DIR"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let visualIterations = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] ?? "20").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 20
|
|
let burstFrames = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] ?? "6").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 6
|
|
let closeDelayMs = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] ?? "70").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 70
|
|
let pattern = (env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] ?? "close_right")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
guard let self else { return }
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
guard let tab = self.selectedWorkspace else {
|
|
self.writeSplitCloseRightTestData(["setupError": "Missing selected workspace"], at: path)
|
|
return
|
|
}
|
|
|
|
guard let topLeftPanelId = tab.focusedPanelId else {
|
|
self.writeSplitCloseRightTestData(["setupError": "Missing initial focused panel"], at: path)
|
|
return
|
|
}
|
|
let initialTerminalReadiness = await self.waitForTerminalPanelReadyForUITest(
|
|
tab: tab,
|
|
panelId: topLeftPanelId
|
|
)
|
|
|
|
guard initialTerminalReadiness.attached,
|
|
initialTerminalReadiness.hasSurface,
|
|
let terminal = tab.terminalPanel(for: topLeftPanelId) else {
|
|
self.writeSplitCloseRightTestData([
|
|
"preTerminalAttached": initialTerminalReadiness.attached ? "1" : "0",
|
|
"preTerminalSurfaceNil": initialTerminalReadiness.hasSurface ? "0" : "1",
|
|
"setupError": "Initial terminal not ready (not attached or surface nil)"
|
|
], at: path)
|
|
return
|
|
}
|
|
|
|
self.writeSplitCloseRightTestData([
|
|
"preTerminalAttached": "1",
|
|
"preTerminalSurfaceNil": terminal.surface.surface == nil ? "1" : "0"
|
|
], at: path)
|
|
|
|
if visualMode {
|
|
// Visual repro mode: repeat the split/close sequence many times and write
|
|
// screenshots to `shotsDir`. This avoids relying on XCUITest to click hover-only
|
|
// close buttons, while still exercising the "close unfocused right tabs" path.
|
|
self.writeSplitCloseRightTestData([
|
|
"visualMode": "1",
|
|
"visualIterations": String(visualIterations),
|
|
"visualDone": "0"
|
|
], at: path)
|
|
|
|
await self.runSplitCloseRightVisualRepro(
|
|
tab: tab,
|
|
topLeftPanelId: topLeftPanelId,
|
|
path: path,
|
|
shotsDir: shotsDir,
|
|
iterations: max(1, min(visualIterations, 60)),
|
|
burstFrames: max(0, min(burstFrames, 80)),
|
|
closeDelayMs: max(0, min(closeDelayMs, 500)),
|
|
pattern: pattern
|
|
)
|
|
|
|
self.writeSplitCloseRightTestData(["visualDone": "1"], at: path)
|
|
return
|
|
}
|
|
|
|
// Layout goal: 2x2 grid (2 top, 2 bottom), then close both right panels.
|
|
// Order matters: split down first, then split right in each row (matches UI shortcut repro).
|
|
guard let bottomLeft = tab.newTerminalSplit(from: topLeftPanelId, orientation: .vertical) else {
|
|
self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-left split"], at: path)
|
|
return
|
|
}
|
|
guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else {
|
|
self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-right split"], at: path)
|
|
return
|
|
}
|
|
tab.focusPanel(topLeftPanelId)
|
|
guard let topRight = tab.newTerminalSplit(from: topLeftPanelId, orientation: .horizontal) else {
|
|
self.writeSplitCloseRightTestData(["setupError": "Failed to create top-right split"], at: path)
|
|
return
|
|
}
|
|
|
|
self.writeSplitCloseRightTestData([
|
|
"tabId": tab.id.uuidString,
|
|
"topLeftPanelId": topLeftPanelId.uuidString,
|
|
"bottomLeftPanelId": bottomLeft.id.uuidString,
|
|
"topRightPanelId": topRight.id.uuidString,
|
|
"bottomRightPanelId": bottomRight.id.uuidString,
|
|
"createdPaneCount": String(tab.bonsplitController.allPaneIds.count),
|
|
"createdPanelCount": String(tab.panels.count)
|
|
], at: path)
|
|
|
|
DebugUIEventCounters.resetEmptyPanelAppearCount()
|
|
|
|
// Close the two right panes via the same path as 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)
|
|
}
|
|
let collapsed = await self.waitForWorkspacePanelsCondition(
|
|
tab: tab,
|
|
timeoutSeconds: 2.0
|
|
) { workspace in
|
|
workspace.panels.count == 1
|
|
}
|
|
if !collapsed {
|
|
write(["setupError": "Timed out collapsing workspace before iteration \(i)", "done": "1"])
|
|
return
|
|
}
|
|
}
|
|
|
|
guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
|
write(["setupError": "Failed to create right split at iteration \(i)", "done": "1"])
|
|
return
|
|
}
|
|
|
|
write([
|
|
"iteration": String(i),
|
|
"leftPanelId": leftPanelId.uuidString,
|
|
"rightPanelId": rightPanel.id.uuidString,
|
|
])
|
|
|
|
tab.focusPanel(rightPanel.id)
|
|
// Wait for the split terminal surface to be attached before sending exit.
|
|
// Without this, very early writes can be dropped during initial surface creation.
|
|
_ = await self.waitForTerminalPanelCondition(
|
|
tab: tab,
|
|
panelId: rightPanel.id,
|
|
timeoutSeconds: 2.0
|
|
) { panel in
|
|
panel.hostedView.window != nil && panel.surface.surface != nil
|
|
}
|
|
// Use an explicit shell exit command for deterministic child-exit behavior across
|
|
// startup timing variance; this still exercises the same SHOW_CHILD_EXITED path.
|
|
rightPanel.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 collapsed = await self.waitForWorkspacePanelsCondition(
|
|
tab: tab,
|
|
timeoutSeconds: 2.0
|
|
) { workspace in
|
|
workspace.panels.count == 2
|
|
}
|
|
if !collapsed {
|
|
write([
|
|
"setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)",
|
|
"done": "1",
|
|
])
|
|
return
|
|
}
|
|
} else if layout == "tdlr_close_bottom_then_exit_top_left" {
|
|
// Alternate repro flow:
|
|
// 1) split top/down
|
|
// 2) split left/right for each row (2x2)
|
|
// 3) close both bottom panes
|
|
// 4) trigger Ctrl+D in top-left
|
|
guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else {
|
|
write(["setupError": "Failed to create bottom-left split", "done": "1"])
|
|
return
|
|
}
|
|
guard let topRight = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
|
write(["setupError": "Failed to create top-right split", "done": "1"])
|
|
return
|
|
}
|
|
guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else {
|
|
write(["setupError": "Failed to create bottom-right split", "done": "1"])
|
|
return
|
|
}
|
|
bottomLeftPanelId = bottomLeft.id.uuidString
|
|
bottomRightPanelId = bottomRight.id.uuidString
|
|
|
|
// Close every pane except the top row; do it one-by-one and wait for model convergence.
|
|
let keepPanels: Set<UUID> = [leftPanelId, topRight.id]
|
|
for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) {
|
|
tab.focusPanel(panelId)
|
|
tab.closePanel(panelId, force: true)
|
|
let closed = await self.waitForWorkspacePanelsCondition(
|
|
tab: tab,
|
|
timeoutSeconds: 1.0
|
|
) { workspace in
|
|
workspace.panels[panelId] == nil
|
|
}
|
|
if !closed {
|
|
write([
|
|
"setupError": "Failed to close bottom pane \(panelId.uuidString)",
|
|
"done": "1",
|
|
])
|
|
return
|
|
}
|
|
}
|
|
exitPanelId = leftPanelId
|
|
|
|
let collapsed = await self.waitForWorkspacePanelsCondition(
|
|
tab: tab,
|
|
timeoutSeconds: 2.0
|
|
) { workspace in
|
|
workspace.panels.count == 2
|
|
}
|
|
if !collapsed {
|
|
write([
|
|
"setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)",
|
|
"done": "1",
|
|
])
|
|
return
|
|
}
|
|
}
|
|
|
|
tab.focusPanel(exitPanelId)
|
|
// Keep child-exit keyboard tests deterministic across user shell configs.
|
|
// `exec cat` exits on a single Ctrl+D and avoids ignore-eof shell settings.
|
|
if let exitPanel = tab.terminalPanel(for: exitPanelId) {
|
|
exitPanel.sendText("exec cat\r")
|
|
}
|
|
|
|
var exitPanelAttachedBeforeCtrlD = false
|
|
var exitPanelHasSurfaceBeforeCtrlD = false
|
|
if !useEarlyTrigger {
|
|
let readiness = await self.waitForTerminalPanelReadyForUITest(
|
|
tab: tab,
|
|
panelId: exitPanelId
|
|
)
|
|
exitPanelAttachedBeforeCtrlD = readiness.attached
|
|
exitPanelHasSurfaceBeforeCtrlD = readiness.hasSurface
|
|
if !(readiness.attached && readiness.hasSurface) {
|
|
write([
|
|
"exitPanelAttachedBeforeCtrlD": readiness.attached ? "1" : "0",
|
|
"exitPanelHasSurfaceBeforeCtrlD": readiness.hasSurface ? "1" : "0",
|
|
"setupError": "Exit panel not ready for Ctrl+D (not attached or surface nil)",
|
|
"done": "1",
|
|
])
|
|
return
|
|
}
|
|
self.ensureFocusedTerminalFirstResponder()
|
|
} else if let exitPanel = tab.terminalPanel(for: exitPanelId) {
|
|
exitPanelAttachedBeforeCtrlD = exitPanel.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 {
|
|
let ready = await self.waitForTerminalPanelCondition(
|
|
tab: tab,
|
|
panelId: exitPanelId,
|
|
timeoutSeconds: 5.0
|
|
) { panel in
|
|
attachedBeforeTrigger = panel.hostedView.window != nil
|
|
hasSurfaceBeforeTrigger = panel.surface.surface != nil
|
|
return attachedBeforeTrigger && hasSurfaceBeforeTrigger
|
|
}
|
|
if !ready,
|
|
tab.terminalPanel(for: exitPanelId) == nil {
|
|
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
|
|
return
|
|
}
|
|
} else if let panel = tab.terminalPanel(for: exitPanelId) {
|
|
attachedBeforeTrigger = panel.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 restorableTabs = tabs
|
|
.filter { !$0.isRemoteWorkspace }
|
|
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
|
|
let workspaceSnapshots = restorableTabs
|
|
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
|
|
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in
|
|
restorableTabs.firstIndex(where: { $0.id == selectedTabId })
|
|
}
|
|
return SessionTabManagerSnapshot(
|
|
selectedWorkspaceIndex: selectedWorkspaceIndex,
|
|
workspaces: workspaceSnapshots
|
|
)
|
|
}
|
|
|
|
private func releaseRestoredAwayWorkspace(_ workspace: Workspace) {
|
|
// Session restore replaces the bootstrap workspace objects with freshly
|
|
// restored ones. Tear the old graph down after the atomic swap so late
|
|
// panel/socket callbacks cannot keep mutating hidden pre-restore state.
|
|
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
|
|
workspace.teardownAllPanels()
|
|
workspace.teardownRemoteConnection()
|
|
workspace.owningTabManager = nil
|
|
}
|
|
|
|
func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) {
|
|
let previousTabs = tabs
|
|
for tab in previousTabs {
|
|
unwireClosedBrowserTracking(for: tab)
|
|
}
|
|
let existingProbeKeys = Set(workspaceGitProbeGenerationByKey.keys)
|
|
.union(workspaceGitProbeTimersByKey.keys)
|
|
for key in existingProbeKeys {
|
|
clearWorkspaceGitProbe(key)
|
|
}
|
|
|
|
// 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.owningTabManager = self
|
|
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)
|
|
fallback.owningTabManager = self
|
|
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
|
|
let existingIds = Set(newTabs.map(\.id))
|
|
pruneBackgroundWorkspaceLoads(existingIds: existingIds)
|
|
sidebarSelectedWorkspaceIds.formIntersection(existingIds)
|
|
for workspace in previousTabs {
|
|
releaseRestoredAwayWorkspace(workspace)
|
|
}
|
|
for workspace in newTabs {
|
|
let terminalPanels = workspace.panels.values.compactMap { $0 as? TerminalPanel }
|
|
for terminalPanel in terminalPanels {
|
|
guard let directory = gitProbeDirectory(for: workspace, panelId: terminalPanel.id) else {
|
|
continue
|
|
}
|
|
scheduleInitialWorkspaceGitMetadataRefresh(
|
|
workspaceId: workspace.id,
|
|
panelId: terminalPanel.id,
|
|
directory: directory
|
|
)
|
|
}
|
|
}
|
|
|
|
if let selectedTabId {
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidFocusTab,
|
|
object: nil,
|
|
userInfo: [GhosttyNotificationKey.tabId: selectedTabId]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Direction Types for Backwards Compatibility
|
|
|
|
/// Split direction for backwards compatibility with old API
|
|
enum SplitDirection {
|
|
case left, right, up, down
|
|
|
|
var isHorizontal: Bool {
|
|
self == .left || self == .right
|
|
}
|
|
|
|
var orientation: SplitOrientation {
|
|
isHorizontal ? .horizontal : .vertical
|
|
}
|
|
|
|
/// If true, insert the new pane on the "first" side (left/top).
|
|
/// If false, insert on the "second" side (right/bottom).
|
|
var insertFirst: Bool {
|
|
self == .left || self == .up
|
|
}
|
|
}
|
|
|
|
/// Resize direction for backwards compatibility
|
|
enum ResizeDirection {
|
|
case left, right, up, down
|
|
|
|
var splitOrientation: String {
|
|
switch self {
|
|
case .left, .right:
|
|
return "horizontal"
|
|
case .up, .down:
|
|
return "vertical"
|
|
}
|
|
}
|
|
|
|
/// A split controls the target pane's right/bottom edge when the target is
|
|
/// the first child, and left/top edge when the target is the second child.
|
|
var requiresPaneInFirstChild: Bool {
|
|
switch self {
|
|
case .right, .down:
|
|
return true
|
|
case .left, .up:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Positive values move the divider toward the second child (right/down).
|
|
var dividerDeltaSign: CGFloat {
|
|
requiresPaneInFirstChild ? 1 : -1
|
|
}
|
|
}
|
|
|
|
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 feedbackComposerRequested = Notification.Name("cmux.feedbackComposerRequested")
|
|
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")
|
|
static let terminalPortalVisibilityDidChange = Notification.Name("cmux.terminalPortalVisibilityDidChange")
|
|
static let browserPortalRegistryDidChange = Notification.Name("cmux.browserPortalRegistryDidChange")
|
|
}
|