* Sidebar ports on own line, wider sidebar, CMUX_PORT env vars - Move listening ports to dedicated sidebar row (removed from branch/directory line) - Allow sidebar to resize up to 2/3 of screen width (was capped at 360px) - Add CMUX_PORT, CMUX_PORT_END, CMUX_PORT_RANGE env vars per workspace - Each workspace gets a dedicated port range (default: base 9100, range 10) - Add settings UI for port base and range size - Add portOrdinal to Workspace, monotonic counter in TabManager Closes #129 * Make port ordinal counter static to avoid overlap across windows Each window creates its own TabManager, so a per-instance counter would reset and reuse port ranges. Making it static ensures unique ranges across all windows. * Fix portOrdinal race: pass through Workspace init instead of setting after The first TerminalPanel is created inside Workspace.init, so setting portOrdinal after init returns meant the initial terminal always got ordinal 0. Pass portOrdinal as an init parameter and set it before the TerminalPanel is created. * Fix P2/P3: snapshot port settings at surface creation, use window screen for sidebar cap P2: Port base/range are now snapshotted on TerminalSurface when the panel is created, so changing settings mid-session won't cause inconsistent CMUX_PORT values across terminals in the same workspace. P3: Sidebar max width now uses NSApp.keyWindow?.screen instead of NSScreen.main, so multi-monitor setups get the correct 2/3 cap for the display the window is actually on. * Fix P1: snapshot port base/range once per app session, not per panel Port base and range size are now static properties on TerminalSurface, initialized once from UserDefaults at first access. This prevents overlapping port ranges across workspaces when settings are changed mid-session (e.g., workspace 1 with range=10 at 9110-9119, then range changed to 5, workspace 2 would overlap at 9110).
2499 lines
105 KiB
Swift
2499 lines
105 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import Foundation
|
|
import Bonsplit
|
|
import CoreVideo
|
|
import Combine
|
|
|
|
// MARK: - Tab Type Alias for Backwards Compatibility
|
|
// The old Tab class is replaced by Workspace
|
|
typealias Tab = Workspace
|
|
|
|
enum NewWorkspacePlacement: String, CaseIterable, Identifiable {
|
|
case top
|
|
case afterCurrent
|
|
case end
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .top:
|
|
return "Top"
|
|
case .afterCurrent:
|
|
return "After current"
|
|
case .end:
|
|
return "End"
|
|
}
|
|
}
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .top:
|
|
return "Insert new workspaces at the top of the list."
|
|
case .afterCurrent:
|
|
return "Insert new workspaces directly after the active workspace."
|
|
case .end:
|
|
return "Append new workspaces to the bottom of the list."
|
|
}
|
|
}
|
|
}
|
|
|
|
enum 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
// Sample the actual IOSurface-backed terminal layer at vsync cadence so UI tests can reliably
|
|
// catch a single compositor-frame blank flash and any transient compositor scaling (stretched text).
|
|
//
|
|
// This is DEBUG-only and used only for UI tests; no polling or display-link loops exist in normal app runtime.
|
|
fileprivate final class VsyncIOSurfaceTimelineState {
|
|
struct Target {
|
|
let label: String
|
|
let sample: @MainActor () -> GhosttySurfaceScrollView.DebugFrameSample?
|
|
}
|
|
|
|
let frameCount: Int
|
|
let closeFrame: Int
|
|
let lock = NSLock()
|
|
|
|
var framesWritten = 0
|
|
var inFlight = false
|
|
var finished = false
|
|
|
|
var scheduledActions: [(frame: Int, action: () -> Void)] = []
|
|
var nextActionIndex: Int = 0
|
|
|
|
var targets: [Target] = []
|
|
|
|
// Results
|
|
var firstBlank: (label: String, frame: Int)?
|
|
var firstSizeMismatch: (label: String, frame: Int, ios: String, expected: String)?
|
|
var trace: [String] = []
|
|
|
|
var link: CVDisplayLink?
|
|
var continuation: CheckedContinuation<Void, Never>?
|
|
|
|
init(frameCount: Int, closeFrame: Int) {
|
|
self.frameCount = frameCount
|
|
self.closeFrame = closeFrame
|
|
}
|
|
|
|
func tryBeginCapture() -> Bool {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
if finished { return false }
|
|
if inFlight { return false }
|
|
inFlight = true
|
|
return true
|
|
}
|
|
|
|
func endCapture() {
|
|
lock.lock()
|
|
inFlight = false
|
|
lock.unlock()
|
|
}
|
|
|
|
func finish() {
|
|
lock.lock()
|
|
if finished {
|
|
lock.unlock()
|
|
return
|
|
}
|
|
finished = true
|
|
let cont = continuation
|
|
continuation = nil
|
|
lock.unlock()
|
|
cont?.resume()
|
|
}
|
|
}
|
|
|
|
fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
|
|
_ displayLink: CVDisplayLink,
|
|
_ inNow: UnsafePointer<CVTimeStamp>,
|
|
_ inOutputTime: UnsafePointer<CVTimeStamp>,
|
|
_ flagsIn: CVOptionFlags,
|
|
_ flagsOut: UnsafeMutablePointer<CVOptionFlags>,
|
|
_ ctx: UnsafeMutableRawPointer?
|
|
) -> CVReturn {
|
|
guard let ctx else { return kCVReturnSuccess }
|
|
let st = Unmanaged<VsyncIOSurfaceTimelineState>.fromOpaque(ctx).takeUnretainedValue()
|
|
if !st.tryBeginCapture() { return kCVReturnSuccess }
|
|
|
|
// Sample on the main thread synchronously so we don't "miss" a single compositor frame.
|
|
// (The previous Task/@MainActor hop could be delayed long enough to skip the blank frame.)
|
|
DispatchQueue.main.sync {
|
|
defer { st.endCapture() }
|
|
guard st.framesWritten < st.frameCount else { return }
|
|
|
|
while st.nextActionIndex < st.scheduledActions.count {
|
|
let next = st.scheduledActions[st.nextActionIndex]
|
|
if next.frame != st.framesWritten { break }
|
|
st.nextActionIndex += 1
|
|
next.action()
|
|
}
|
|
|
|
for t in st.targets {
|
|
guard let s = t.sample() else { continue }
|
|
|
|
let iosW = s.iosurfaceWidthPx
|
|
let iosH = s.iosurfaceHeightPx
|
|
let expW = s.expectedWidthPx
|
|
let expH = s.expectedHeightPx
|
|
let gravity = s.layerContentsGravity
|
|
let hasDimensions = iosW > 0 && iosH > 0 && expW > 0 && expH > 0
|
|
let dw = hasDimensions ? abs(iosW - expW) : 0
|
|
let dh = hasDimensions ? abs(iosH - expH) : 0
|
|
let hasSizeMismatch = hasDimensions && (dw > 2 || dh > 2)
|
|
let stretchRisk = (gravity == CALayerContentsGravity.resize.rawValue)
|
|
|
|
// Ignore setup/warmup frames before the close action. We only care about
|
|
// regressions that happen at/after the close mutation.
|
|
if st.firstBlank == nil, st.framesWritten >= st.closeFrame, s.isProbablyBlank {
|
|
st.firstBlank = (label: t.label, frame: st.framesWritten)
|
|
}
|
|
|
|
if st.firstSizeMismatch == nil,
|
|
st.framesWritten >= st.closeFrame,
|
|
stretchRisk,
|
|
hasSizeMismatch {
|
|
st.firstSizeMismatch = (
|
|
label: t.label,
|
|
frame: st.framesWritten,
|
|
ios: "\(iosW)x\(iosH)",
|
|
expected: "\(expW)x\(expH)"
|
|
)
|
|
}
|
|
|
|
if st.trace.count < 200 {
|
|
st.trace.append("\(st.framesWritten):\(t.label):blank=\(s.isProbablyBlank ? 1 : 0):ios=\(iosW)x\(iosH):exp=\(expW)x\(expH):gravity=\(gravity):key=\(s.layerContentsKey)")
|
|
}
|
|
}
|
|
|
|
st.framesWritten += 1
|
|
}
|
|
|
|
// Stop/resume outside the main-thread sync block to avoid reentrancy issues.
|
|
if st.framesWritten >= st.frameCount, let link = st.link {
|
|
CVDisplayLinkStop(link)
|
|
st.finish()
|
|
Unmanaged<VsyncIOSurfaceTimelineState>.fromOpaque(ctx).release()
|
|
}
|
|
|
|
return kCVReturnSuccess
|
|
}
|
|
#endif
|
|
|
|
@MainActor
|
|
class TabManager: ObservableObject {
|
|
@Published var tabs: [Workspace] = []
|
|
@Published private(set) var isWorkspaceCycleHot: Bool = false
|
|
|
|
/// Global monotonically increasing counter for CMUX_PORT ordinal assignment.
|
|
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
|
|
private static var nextPortOrdinal: Int = 0
|
|
@Published var selectedTabId: UUID? {
|
|
didSet {
|
|
guard selectedTabId != oldValue else { return }
|
|
let previousTabId = oldValue
|
|
if let previousTabId,
|
|
let previousPanelId = focusedPanelId(for: previousTabId) {
|
|
lastFocusedPanelByTab[previousTabId] = previousPanelId
|
|
}
|
|
if !isNavigatingHistory, let selectedTabId {
|
|
recordTabInHistory(selectedTabId)
|
|
}
|
|
#if DEBUG
|
|
let switchId = debugWorkspaceSwitchId
|
|
let switchDtMs = debugWorkspaceSwitchStartTime > 0
|
|
? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000
|
|
: 0
|
|
dlog(
|
|
"ws.select.didSet id=\(switchId) from=\(Self.debugShortWorkspaceId(previousTabId)) " +
|
|
"to=\(Self.debugShortWorkspaceId(selectedTabId)) dt=\(Self.debugMsText(switchDtMs))"
|
|
)
|
|
#endif
|
|
selectionSideEffectsGeneration &+= 1
|
|
let generation = selectionSideEffectsGeneration
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, self.selectionSideEffectsGeneration == generation else { return }
|
|
self.focusSelectedTabPanel(previousTabId: previousTabId)
|
|
self.updateWindowTitleForSelectedTab()
|
|
if let selectedTabId = self.selectedTabId {
|
|
self.markFocusedPanelReadIfActive(tabId: selectedTabId)
|
|
}
|
|
#if DEBUG
|
|
let dtMs = self.debugWorkspaceSwitchStartTime > 0
|
|
? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000
|
|
: 0
|
|
dlog(
|
|
"ws.select.asyncDone id=\(self.debugWorkspaceSwitchId) dt=\(Self.debugMsText(dtMs)) " +
|
|
"selected=\(Self.debugShortWorkspaceId(self.selectedTabId))"
|
|
)
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
private var observers: [NSObjectProtocol] = []
|
|
private var suppressFocusFlash = false
|
|
private var lastFocusedPanelByTab: [UUID: UUID] = [:]
|
|
|
|
// Recent tab history for back/forward navigation (like browser history)
|
|
private var tabHistory: [UUID] = []
|
|
private var historyIndex: Int = -1
|
|
private var isNavigatingHistory = false
|
|
private let maxHistorySize = 50
|
|
private var selectionSideEffectsGeneration: UInt64 = 0
|
|
private var workspaceCycleGeneration: UInt64 = 0
|
|
private var workspaceCycleCooldownTask: Task<Void, Never>?
|
|
private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)?
|
|
#if DEBUG
|
|
private var debugWorkspaceSwitchCounter: UInt64 = 0
|
|
private var debugWorkspaceSwitchId: UInt64 = 0
|
|
private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0
|
|
#endif
|
|
|
|
#if DEBUG
|
|
private var didSetupSplitCloseRightUITest = false
|
|
private var didSetupUITestFocusShortcuts = false
|
|
private var didSetupChildExitSplitUITest = false
|
|
private var didSetupChildExitKeyboardUITest = false
|
|
private var uiTestCancellables = Set<AnyCancellable>()
|
|
#endif
|
|
|
|
init(initialWorkingDirectory: String? = nil) {
|
|
addWorkspace(workingDirectory: initialWorkingDirectory)
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .ghosttyDidSetTitle,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
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 }
|
|
self.updatePanelTitle(tabId: tabId, panelId: surfaceId, title: title)
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .ghosttyDidFocusSurface,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification 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 }
|
|
self.markPanelReadOnFocusIfActive(tabId: tabId, panelId: surfaceId)
|
|
})
|
|
|
|
#if DEBUG
|
|
setupUITestFocusShortcutsIfNeeded()
|
|
setupSplitCloseRightUITestIfNeeded()
|
|
setupChildExitSplitUITestIfNeeded()
|
|
setupChildExitKeyboardUITestIfNeeded()
|
|
#endif
|
|
}
|
|
|
|
deinit {
|
|
workspaceCycleCooldownTask?.cancel()
|
|
}
|
|
|
|
var selectedWorkspace: Workspace? {
|
|
guard let selectedTabId else { return nil }
|
|
return tabs.first(where: { $0.id == selectedTabId })
|
|
}
|
|
|
|
// Keep selectedTab as convenience alias
|
|
var selectedTab: Workspace? { selectedWorkspace }
|
|
|
|
// MARK: - Surface/Panel Compatibility Layer
|
|
|
|
/// Returns the focused terminal surface for the selected workspace
|
|
var selectedSurface: TerminalSurface? {
|
|
selectedWorkspace?.focusedTerminalPanel?.surface
|
|
}
|
|
|
|
/// Returns the focused panel's terminal panel (if it is a terminal)
|
|
var selectedTerminalPanel: TerminalPanel? {
|
|
selectedWorkspace?.focusedTerminalPanel
|
|
}
|
|
|
|
var isFindVisible: Bool {
|
|
selectedTerminalPanel?.searchState != nil
|
|
}
|
|
|
|
var canUseSelectionForFind: Bool {
|
|
selectedTerminalPanel?.hasSelection() == true
|
|
}
|
|
|
|
func startSearch() {
|
|
guard let panel = selectedTerminalPanel else { return }
|
|
if panel.searchState == nil {
|
|
panel.searchState = TerminalSurface.SearchState()
|
|
}
|
|
NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
|
|
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
|
|
_ = panel.performBindingAction("start_search")
|
|
}
|
|
|
|
func searchSelection() {
|
|
guard let panel = selectedTerminalPanel else { return }
|
|
if panel.searchState == nil {
|
|
panel.searchState = TerminalSurface.SearchState()
|
|
}
|
|
NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
|
|
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
|
|
_ = panel.performBindingAction("search_selection")
|
|
}
|
|
|
|
func findNext() {
|
|
_ = selectedTerminalPanel?.performBindingAction("search:next")
|
|
}
|
|
|
|
func findPrevious() {
|
|
_ = selectedTerminalPanel?.performBindingAction("search:previous")
|
|
}
|
|
|
|
func hideFind() {
|
|
selectedTerminalPanel?.searchState = nil
|
|
}
|
|
|
|
@discardableResult
|
|
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil) -> Workspace {
|
|
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
|
|
let ordinal = Self.nextPortOrdinal
|
|
Self.nextPortOrdinal += 1
|
|
let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal)
|
|
let insertIndex = newTabInsertIndex()
|
|
if insertIndex >= 0 && insertIndex <= tabs.count {
|
|
tabs.insert(newWorkspace, at: insertIndex)
|
|
} else {
|
|
tabs.append(newWorkspace)
|
|
}
|
|
selectedTabId = newWorkspace.id
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidFocusTab,
|
|
object: nil,
|
|
userInfo: [GhosttyNotificationKey.tabId: newWorkspace.id]
|
|
)
|
|
#if DEBUG
|
|
UITestRecorder.incrementInt("addTabInvocations")
|
|
UITestRecorder.record([
|
|
"tabCount": String(tabs.count),
|
|
"selectedTabId": newWorkspace.id.uuidString
|
|
])
|
|
#endif
|
|
return newWorkspace
|
|
}
|
|
|
|
// Keep addTab as convenience alias
|
|
@discardableResult
|
|
func addTab() -> Workspace { addWorkspace() }
|
|
|
|
private func normalizedWorkingDirectory(_ directory: String?) -> String? {
|
|
guard let directory else { return nil }
|
|
let normalized = normalizeDirectory(directory)
|
|
let trimmed = normalized.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : normalized
|
|
}
|
|
|
|
private func newTabInsertIndex() -> Int {
|
|
let placement = WorkspacePlacementSettings.current()
|
|
let pinnedCount = tabs.filter { $0.isPinned }.count
|
|
let selectedIndex = selectedTabId.flatMap { tabId in
|
|
tabs.firstIndex(where: { $0.id == tabId })
|
|
}
|
|
let selectedIsPinned = selectedIndex.map { tabs[$0].isPinned } ?? false
|
|
return WorkspacePlacementSettings.insertionIndex(
|
|
placement: placement,
|
|
selectedIndex: selectedIndex,
|
|
selectedIsPinned: selectedIsPinned,
|
|
pinnedCount: pinnedCount,
|
|
totalCount: tabs.count
|
|
)
|
|
}
|
|
|
|
private func preferredWorkingDirectoryForNewTab() -> String? {
|
|
guard let selectedTabId,
|
|
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
|
|
return nil
|
|
}
|
|
let focusedDirectory = tab.focusedPanelId
|
|
.flatMap { tab.panelDirectories[$0] }
|
|
let candidate = focusedDirectory ?? tab.currentDirectory
|
|
let normalized = normalizeDirectory(candidate)
|
|
let trimmed = normalized.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : normalized
|
|
}
|
|
|
|
func moveTabToTop(_ tabId: UUID) {
|
|
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
|
guard index != 0 else { return }
|
|
let tab = tabs.remove(at: index)
|
|
let pinnedCount = tabs.filter { $0.isPinned }.count
|
|
let insertIndex = tab.isPinned ? 0 : pinnedCount
|
|
tabs.insert(tab, at: insertIndex)
|
|
}
|
|
|
|
func moveTabsToTop(_ tabIds: Set<UUID>) {
|
|
guard !tabIds.isEmpty else { return }
|
|
let selectedTabs = tabs.filter { tabIds.contains($0.id) }
|
|
guard !selectedTabs.isEmpty else { return }
|
|
let remainingTabs = tabs.filter { !tabIds.contains($0.id) }
|
|
let selectedPinned = selectedTabs.filter { $0.isPinned }
|
|
let selectedUnpinned = selectedTabs.filter { !$0.isPinned }
|
|
let remainingPinned = remainingTabs.filter { $0.isPinned }
|
|
let remainingUnpinned = remainingTabs.filter { !$0.isPinned }
|
|
tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned
|
|
}
|
|
|
|
@discardableResult
|
|
func reorderWorkspace(tabId: UUID, toIndex targetIndex: Int) -> Bool {
|
|
guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false }
|
|
if tabs.count <= 1 { return true }
|
|
|
|
let clamped = max(0, min(targetIndex, tabs.count - 1))
|
|
if currentIndex == clamped { return true }
|
|
|
|
let workspace = tabs.remove(at: currentIndex)
|
|
tabs.insert(workspace, at: clamped)
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func reorderWorkspace(tabId: UUID, before beforeId: UUID? = nil, after afterId: UUID? = nil) -> Bool {
|
|
guard tabs.contains(where: { $0.id == tabId }) else { return false }
|
|
if let beforeId {
|
|
guard let idx = tabs.firstIndex(where: { $0.id == beforeId }) else { return false }
|
|
return reorderWorkspace(tabId: tabId, toIndex: idx)
|
|
}
|
|
if let afterId {
|
|
guard let idx = tabs.firstIndex(where: { $0.id == afterId }) else { return false }
|
|
return reorderWorkspace(tabId: tabId, toIndex: idx + 1)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func setCustomTitle(tabId: UUID, title: String?) {
|
|
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
|
tabs[index].setCustomTitle(title)
|
|
if selectedTabId == tabId {
|
|
updateWindowTitle(for: tabs[index])
|
|
}
|
|
}
|
|
|
|
func clearCustomTitle(tabId: UUID) {
|
|
setCustomTitle(tabId: tabId, title: nil)
|
|
}
|
|
|
|
func togglePin(tabId: UUID) {
|
|
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
|
let tab = tabs[index]
|
|
setPinned(tab, pinned: !tab.isPinned)
|
|
}
|
|
|
|
func setPinned(_ tab: Workspace, pinned: Bool) {
|
|
guard tab.isPinned != pinned else { return }
|
|
tab.isPinned = pinned
|
|
reorderTabForPinnedState(tab)
|
|
}
|
|
|
|
private func reorderTabForPinnedState(_ tab: Workspace) {
|
|
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
|
|
tabs.remove(at: index)
|
|
let pinnedCount = tabs.filter { $0.isPinned }.count
|
|
let insertIndex = min(pinnedCount, tabs.count)
|
|
tabs.insert(tab, at: insertIndex)
|
|
}
|
|
|
|
// MARK: - Surface Directory Updates (Backwards Compatibility)
|
|
|
|
func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
|
let normalized = normalizeDirectory(directory)
|
|
tab.updatePanelDirectory(panelId: surfaceId, directory: normalized)
|
|
}
|
|
|
|
private func normalizeDirectory(_ directory: String) -> String {
|
|
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return directory }
|
|
if trimmed.hasPrefix("file://"), let url = URL(string: trimmed) {
|
|
if !url.path.isEmpty {
|
|
return url.path
|
|
}
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func closeWorkspace(_ workspace: Workspace) {
|
|
guard tabs.count > 1 else { return }
|
|
|
|
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
|
|
|
|
if let index = tabs.firstIndex(where: { $0.id == workspace.id }) {
|
|
tabs.remove(at: index)
|
|
|
|
if selectedTabId == workspace.id {
|
|
// Keep the "focused index" stable when possible:
|
|
// - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up).
|
|
// - Otherwise (we closed the last workspace), focus the new last workspace (i-1).
|
|
let newIndex = min(index, max(0, tabs.count - 1))
|
|
selectedTabId = tabs[newIndex].id
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Detach a workspace from this window without closing its panels.
|
|
/// Used by the socket API for cross-window moves.
|
|
@discardableResult
|
|
func detachWorkspace(tabId: UUID) -> Workspace? {
|
|
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
|
|
|
|
let removed = tabs.remove(at: index)
|
|
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) {
|
|
let insertIndex: Int = {
|
|
guard let index else { return tabs.count }
|
|
return max(0, min(index, tabs.count))
|
|
}()
|
|
tabs.insert(workspace, at: insertIndex)
|
|
if select {
|
|
selectedTabId = workspace.id
|
|
}
|
|
}
|
|
|
|
// Keep closeTab as convenience alias
|
|
func closeTab(_ tab: Workspace) { closeWorkspace(tab) }
|
|
func closeCurrentTabWithConfirmation() { closeCurrentWorkspaceWithConfirmation() }
|
|
|
|
func closeCurrentWorkspace() {
|
|
guard let selectedId = selectedTabId,
|
|
let workspace = tabs.first(where: { $0.id == selectedId }) else { return }
|
|
closeWorkspace(workspace)
|
|
}
|
|
|
|
func closeCurrentPanelWithConfirmation() {
|
|
#if DEBUG
|
|
UITestRecorder.incrementInt("closePanelInvocations")
|
|
#endif
|
|
guard let selectedId = selectedTabId,
|
|
let tab = tabs.first(where: { $0.id == selectedId }),
|
|
let focusedPanelId = tab.focusedPanelId else { return }
|
|
closePanelWithConfirmation(tab: tab, panelId: focusedPanelId)
|
|
}
|
|
|
|
func closeCurrentWorkspaceWithConfirmation() {
|
|
#if DEBUG
|
|
UITestRecorder.incrementInt("closeTabInvocations")
|
|
#endif
|
|
guard let selectedId = selectedTabId,
|
|
let workspace = tabs.first(where: { $0.id == selectedId }) else { return }
|
|
closeWorkspaceWithConfirmation(workspace)
|
|
}
|
|
|
|
func closeWorkspaceWithConfirmation(_ workspace: Workspace) {
|
|
closeWorkspaceIfRunningProcess(workspace)
|
|
}
|
|
|
|
func closeWorkspaceWithConfirmation(tabId: UUID) {
|
|
guard let workspace = tabs.first(where: { $0.id == tabId }) else { return }
|
|
closeWorkspaceWithConfirmation(workspace)
|
|
}
|
|
|
|
func selectWorkspace(_ workspace: Workspace) {
|
|
selectedTabId = workspace.id
|
|
}
|
|
|
|
// Keep selectTab as convenience alias
|
|
func selectTab(_ tab: Workspace) { selectWorkspace(tab) }
|
|
|
|
private func confirmClose(title: String, message: String, acceptCmdD: Bool) -> Bool {
|
|
let alert = NSAlert()
|
|
alert.messageText = title
|
|
alert.informativeText = message
|
|
alert.alertStyle = .warning
|
|
alert.addButton(withTitle: "Close")
|
|
alert.addButton(withTitle: "Cancel")
|
|
|
|
// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save").
|
|
// We only opt into this for the "close last workspace => close window" path to avoid
|
|
// conflicting with app-level Cmd+D (split right) during normal usage.
|
|
if acceptCmdD, let closeButton = alert.buttons.first {
|
|
closeButton.keyEquivalent = "d"
|
|
closeButton.keyEquivalentModifierMask = [.command]
|
|
|
|
// Keep Return/Enter behavior by explicitly setting the default button cell.
|
|
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
|
|
}
|
|
|
|
return alert.runModal() == .alertFirstButtonReturn
|
|
}
|
|
|
|
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) {
|
|
let willCloseWindow = tabs.count <= 1
|
|
if workspaceNeedsConfirmClose(workspace),
|
|
!confirmClose(
|
|
title: "Close workspace?",
|
|
message: "This will close the workspace and all of its panels.",
|
|
acceptCmdD: willCloseWindow
|
|
) {
|
|
return
|
|
}
|
|
if tabs.count <= 1 {
|
|
// Last workspace in this window: close the window (Cmd+Shift+W behavior).
|
|
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
|
|
} else {
|
|
closeWorkspace(workspace)
|
|
}
|
|
}
|
|
|
|
private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) {
|
|
// Cmd+W closes the focused Bonsplit tab (a "tab" in the UI). When the workspace only has
|
|
// a single tab left, closing it should close the workspace (and possibly the window),
|
|
// rather than creating a replacement terminal.
|
|
let isLastTabInWorkspace = tab.panels.count <= 1
|
|
if isLastTabInWorkspace {
|
|
let willCloseWindow = tabs.count <= 1
|
|
let needsConfirm = workspaceNeedsConfirmClose(tab)
|
|
if needsConfirm {
|
|
let message = willCloseWindow
|
|
? "This will close the last tab and close the window."
|
|
: "This will close the last tab and close its workspace."
|
|
guard confirmClose(
|
|
title: "Close tab?",
|
|
message: message,
|
|
acceptCmdD: willCloseWindow
|
|
) else { return }
|
|
}
|
|
|
|
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id)
|
|
if willCloseWindow {
|
|
AppDelegate.shared?.closeMainWindowContainingTabId(tab.id)
|
|
} else {
|
|
closeWorkspace(tab)
|
|
}
|
|
return
|
|
}
|
|
|
|
if let terminalPanel = tab.terminalPanel(for: panelId),
|
|
terminalPanel.needsConfirmClose() {
|
|
guard confirmClose(
|
|
title: "Close tab?",
|
|
message: "This will close the current tab.",
|
|
acceptCmdD: false
|
|
) else { return }
|
|
}
|
|
|
|
// We already confirmed (if needed); bypass Bonsplit's delegate gating.
|
|
tab.closePanel(panelId, force: true)
|
|
}
|
|
|
|
func closePanelWithConfirmation(tabId: UUID, surfaceId: UUID) {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
|
closePanelWithConfirmation(tab: tab, panelId: surfaceId)
|
|
}
|
|
|
|
/// Runtime close requests from Ghostty should only ever target the specific surface.
|
|
/// They must not escalate into workspace/window-close semantics for "last tab".
|
|
func closeRuntimeSurfaceWithConfirmation(tabId: UUID, surfaceId: UUID) {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
|
guard tab.panels[surfaceId] != nil else { return }
|
|
|
|
if let terminalPanel = tab.terminalPanel(for: surfaceId),
|
|
terminalPanel.needsConfirmClose() {
|
|
guard confirmClose(
|
|
title: "Close tab?",
|
|
message: "This will close the current tab.",
|
|
acceptCmdD: false
|
|
) else { return }
|
|
}
|
|
|
|
_ = tab.closePanel(surfaceId, force: true)
|
|
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId)
|
|
}
|
|
|
|
/// Runtime close requests from Ghostty without confirmation (e.g. child-exit).
|
|
/// This path must only close the addressed surface and must never close the workspace window.
|
|
func closeRuntimeSurface(tabId: UUID, surfaceId: UUID) {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
|
guard tab.panels[surfaceId] != nil else { return }
|
|
|
|
// Keep AppKit first responder in sync with workspace focus before routing the close.
|
|
// If split reparenting caused a temporary model/view mismatch, fallback close logic in
|
|
// Workspace.closePanel uses focused selection to resolve the correct tab deterministically.
|
|
reconcileFocusedPanelFromFirstResponderForKeyboard()
|
|
_ = tab.closePanel(surfaceId, force: true)
|
|
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId)
|
|
}
|
|
|
|
/// Close a panel because its child process exited (e.g. the user hit Ctrl+D).
|
|
///
|
|
/// This should never prompt: the process is already gone, and Ghostty emits the
|
|
/// `SHOW_CHILD_EXITED` action specifically so the host app can decide what to do.
|
|
func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) {
|
|
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
|
|
}
|
|
|
|
/// Backwards compatibility: returns the focused surface ID
|
|
func focusedSurfaceId(for tabId: UUID) -> UUID? {
|
|
focusedPanelId(for: tabId)
|
|
}
|
|
|
|
func rememberFocusedSurface(tabId: UUID, surfaceId: UUID) {
|
|
lastFocusedPanelByTab[tabId] = surfaceId
|
|
}
|
|
|
|
func applyWindowBackgroundForSelectedTab() {
|
|
guard let selectedTabId,
|
|
let tab = tabs.first(where: { $0.id == selectedTabId }),
|
|
let terminalPanel = tab.focusedTerminalPanel else { return }
|
|
terminalPanel.applyWindowBackgroundIfActive()
|
|
}
|
|
|
|
private func focusSelectedTabPanel(previousTabId: UUID?) {
|
|
guard let selectedTabId,
|
|
let tab = tabs.first(where: { $0.id == selectedTabId }) else { return }
|
|
|
|
// Try to restore previous focus
|
|
if let restoredPanelId = lastFocusedPanelByTab[selectedTabId],
|
|
tab.panels[restoredPanelId] != nil,
|
|
tab.focusedPanelId != restoredPanelId {
|
|
tab.focusPanel(restoredPanelId)
|
|
}
|
|
|
|
// Focus the panel
|
|
guard let panelId = tab.focusedPanelId,
|
|
let panel = tab.panels[panelId] else { return }
|
|
|
|
// Defer unfocusing the previous workspace's panel until ContentView confirms handoff
|
|
// completion (new workspace has focus or timeout fallback), to avoid a visible freeze gap.
|
|
if let previousTabId,
|
|
let previousTab = tabs.first(where: { $0.id == previousTabId }),
|
|
let previousPanelId = previousTab.focusedPanelId,
|
|
previousTab.panels[previousPanelId] != nil {
|
|
replacePendingWorkspaceUnfocusTarget(
|
|
with: (tabId: previousTabId, panelId: previousPanelId)
|
|
)
|
|
}
|
|
|
|
panel.focus()
|
|
|
|
// For terminal panels, ensure proper focus handling
|
|
if let terminalPanel = panel as? TerminalPanel {
|
|
terminalPanel.hostedView.ensureFocus(for: selectedTabId, surfaceId: panelId)
|
|
}
|
|
}
|
|
|
|
func completePendingWorkspaceUnfocus(reason: String) {
|
|
guard let pending = pendingWorkspaceUnfocusTarget else { return }
|
|
// If this tab became selected again before handoff completion, drop the stale
|
|
// pending entry so it cannot be flushed later and deactivate the selected workspace.
|
|
guard Self.shouldUnfocusPendingWorkspace(
|
|
pendingTabId: pending.tabId,
|
|
selectedTabId: selectedTabId
|
|
) else {
|
|
pendingWorkspaceUnfocusTarget = nil
|
|
#if DEBUG
|
|
dlog(
|
|
"ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=selected_again"
|
|
)
|
|
#endif
|
|
return
|
|
}
|
|
pendingWorkspaceUnfocusTarget = nil
|
|
unfocusWorkspacePanel(tabId: pending.tabId, panelId: pending.panelId)
|
|
#if DEBUG
|
|
if let snapshot = debugCurrentWorkspaceSwitchSnapshot() {
|
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
|
dlog(
|
|
"ws.unfocus.complete id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " +
|
|
"tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)"
|
|
)
|
|
} else {
|
|
dlog(
|
|
"ws.unfocus.complete id=none tab=\(Self.debugShortWorkspaceId(pending.tabId)) " +
|
|
"panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)"
|
|
)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func replacePendingWorkspaceUnfocusTarget(with next: (tabId: UUID, panelId: UUID)) {
|
|
if let current = pendingWorkspaceUnfocusTarget,
|
|
current.tabId == next.tabId,
|
|
current.panelId == next.panelId {
|
|
return
|
|
}
|
|
|
|
if let current = pendingWorkspaceUnfocusTarget {
|
|
// Never unfocus the currently selected workspace when replacing stale pending state.
|
|
if Self.shouldUnfocusPendingWorkspace(
|
|
pendingTabId: current.tabId,
|
|
selectedTabId: selectedTabId
|
|
) {
|
|
unfocusWorkspacePanel(tabId: current.tabId, panelId: current.panelId)
|
|
#if DEBUG
|
|
dlog(
|
|
"ws.unfocus.flush tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced"
|
|
)
|
|
#endif
|
|
} else {
|
|
#if DEBUG
|
|
dlog(
|
|
"ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced_selected"
|
|
)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
pendingWorkspaceUnfocusTarget = next
|
|
#if DEBUG
|
|
if let snapshot = debugCurrentWorkspaceSwitchSnapshot() {
|
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
|
dlog(
|
|
"ws.unfocus.defer id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " +
|
|
"tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))"
|
|
)
|
|
} else {
|
|
dlog(
|
|
"ws.unfocus.defer id=none tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))"
|
|
)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func unfocusWorkspacePanel(tabId: UUID, panelId: UUID) {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }),
|
|
let panel = tab.panels[panelId] else { return }
|
|
panel.unfocus()
|
|
}
|
|
|
|
static func shouldUnfocusPendingWorkspace(pendingTabId: UUID, selectedTabId: UUID?) -> Bool {
|
|
selectedTabId != pendingTabId
|
|
}
|
|
|
|
private func markFocusedPanelReadIfActive(tabId: UUID) {
|
|
let shouldSuppressFlash = suppressFocusFlash
|
|
suppressFocusFlash = false
|
|
guard !shouldSuppressFlash else { return }
|
|
guard AppFocusState.isAppActive() else { return }
|
|
guard let panelId = focusedPanelId(for: tabId) else { return }
|
|
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
|
|
}
|
|
|
|
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
|
|
guard selectedTabId == tabId else { return }
|
|
guard !suppressFocusFlash else { return }
|
|
guard AppFocusState.isAppActive() else { return }
|
|
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
|
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return }
|
|
if let tab = tabs.first(where: { $0.id == tabId }) {
|
|
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
|
|
}
|
|
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
|
|
}
|
|
|
|
private func updatePanelTitle(tabId: UUID, panelId: UUID, title: String) {
|
|
guard !title.isEmpty else { return }
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
|
tab.updatePanelTitle(panelId: panelId, title: title)
|
|
|
|
// Update window title if this is the selected tab and focused panel
|
|
if selectedTabId == tabId && tab.focusedPanelId == panelId {
|
|
updateWindowTitle(for: tab)
|
|
}
|
|
}
|
|
|
|
func focusedSurfaceTitleDidChange(tabId: UUID) {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }),
|
|
let focusedPanelId = tab.focusedPanelId,
|
|
let title = tab.panelTitles[focusedPanelId] else { return }
|
|
tab.applyProcessTitle(title)
|
|
if selectedTabId == tabId {
|
|
updateWindowTitle(for: tab)
|
|
}
|
|
}
|
|
|
|
private func updateWindowTitleForSelectedTab() {
|
|
guard let selectedTabId,
|
|
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
|
|
updateWindowTitle(for: nil)
|
|
return
|
|
}
|
|
updateWindowTitle(for: tab)
|
|
}
|
|
|
|
private func updateWindowTitle(for tab: Workspace?) {
|
|
let title = windowTitle(for: tab)
|
|
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first
|
|
targetWindow?.title = title
|
|
}
|
|
|
|
private func windowTitle(for tab: Workspace?) -> String {
|
|
guard let tab else { return "cmux" }
|
|
let trimmedTitle = tab.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmedTitle.isEmpty {
|
|
return trimmedTitle
|
|
}
|
|
let trimmedDirectory = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmedDirectory.isEmpty ? "cmux" : trimmedDirectory
|
|
}
|
|
|
|
func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) {
|
|
guard tabs.contains(where: { $0.id == tabId }) else { return }
|
|
selectedTabId = tabId
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidFocusTab,
|
|
object: nil,
|
|
userInfo: [GhosttyNotificationKey.tabId: tabId]
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
NSApp.unhide(nil)
|
|
if let window = NSApp.keyWindow ?? NSApp.windows.first {
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
}
|
|
|
|
if let surfaceId {
|
|
if !suppressFlash {
|
|
focusSurface(tabId: tabId, surfaceId: surfaceId)
|
|
} else if let tab = tabs.first(where: { $0.id == tabId }) {
|
|
tab.focusPanel(surfaceId)
|
|
}
|
|
}
|
|
}
|
|
|
|
func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) {
|
|
let wasSelected = selectedTabId == tabId
|
|
let desiredPanelId = surfaceId ?? tabs.first(where: { $0.id == tabId })?.focusedPanelId
|
|
#if DEBUG
|
|
if let desiredPanelId {
|
|
AppDelegate.shared?.armJumpUnreadFocusRecord(tabId: tabId, surfaceId: desiredPanelId)
|
|
}
|
|
#endif
|
|
suppressFocusFlash = true
|
|
focusTab(tabId, surfaceId: desiredPanelId, suppressFlash: true)
|
|
if wasSelected {
|
|
suppressFocusFlash = false
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
guard let self,
|
|
let tab = self.tabs.first(where: { $0.id == tabId }) else { return }
|
|
let targetPanelId = desiredPanelId ?? tab.focusedPanelId
|
|
guard let targetPanelId,
|
|
tab.panels[targetPanelId] != nil else { return }
|
|
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
|
|
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetPanelId) else { return }
|
|
tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true)
|
|
notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId)
|
|
}
|
|
}
|
|
|
|
func focusSurface(tabId: UUID, surfaceId: UUID) {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
|
tab.focusPanel(surfaceId)
|
|
}
|
|
|
|
func selectNextTab() {
|
|
guard let currentId = selectedTabId,
|
|
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
|
|
let nextIndex = (currentIndex + 1) % tabs.count
|
|
#if DEBUG
|
|
let nextId = tabs[nextIndex].id
|
|
debugWorkspaceSwitchCounter &+= 1
|
|
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
|
|
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
|
|
dlog(
|
|
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=next from=\(Self.debugShortWorkspaceId(currentId)) " +
|
|
"to=\(Self.debugShortWorkspaceId(nextId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
|
|
)
|
|
#endif
|
|
activateWorkspaceCycleHotWindow()
|
|
selectedTabId = tabs[nextIndex].id
|
|
}
|
|
|
|
func selectPreviousTab() {
|
|
guard let currentId = selectedTabId,
|
|
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
|
|
let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count
|
|
#if DEBUG
|
|
let prevId = tabs[prevIndex].id
|
|
debugWorkspaceSwitchCounter &+= 1
|
|
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
|
|
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
|
|
dlog(
|
|
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=prev from=\(Self.debugShortWorkspaceId(currentId)) " +
|
|
"to=\(Self.debugShortWorkspaceId(prevId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
|
|
)
|
|
#endif
|
|
activateWorkspaceCycleHotWindow()
|
|
selectedTabId = tabs[prevIndex].id
|
|
}
|
|
|
|
private func activateWorkspaceCycleHotWindow() {
|
|
workspaceCycleGeneration &+= 1
|
|
let generation = workspaceCycleGeneration
|
|
#if DEBUG
|
|
let switchId = debugWorkspaceSwitchId
|
|
let switchDtMs = debugWorkspaceSwitchStartTime > 0
|
|
? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000
|
|
: 0
|
|
#endif
|
|
if !isWorkspaceCycleHot {
|
|
isWorkspaceCycleHot = true
|
|
#if DEBUG
|
|
dlog(
|
|
"ws.hot.on id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
let hadPendingCooldown = workspaceCycleCooldownTask != nil
|
|
workspaceCycleCooldownTask?.cancel()
|
|
#if DEBUG
|
|
if hadPendingCooldown {
|
|
dlog(
|
|
"ws.hot.cancelPrev id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))"
|
|
)
|
|
}
|
|
#endif
|
|
workspaceCycleCooldownTask = Task { [weak self, generation] in
|
|
do {
|
|
try await Task.sleep(nanoseconds: 220_000_000)
|
|
} catch {
|
|
#if DEBUG
|
|
await MainActor.run {
|
|
guard let self else { return }
|
|
let dtMs = self.debugWorkspaceSwitchStartTime > 0
|
|
? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000
|
|
: 0
|
|
dlog(
|
|
"ws.hot.cooldownCanceled id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))"
|
|
)
|
|
}
|
|
#endif
|
|
return
|
|
}
|
|
await MainActor.run {
|
|
guard let self else { return }
|
|
guard self.workspaceCycleGeneration == generation else { return }
|
|
#if DEBUG
|
|
let dtMs = self.debugWorkspaceSwitchStartTime > 0
|
|
? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000
|
|
: 0
|
|
dlog(
|
|
"ws.hot.off id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))"
|
|
)
|
|
#endif
|
|
self.isWorkspaceCycleHot = false
|
|
self.workspaceCycleCooldownTask = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
func debugCurrentWorkspaceSwitchSnapshot() -> (id: UInt64, startedAt: CFTimeInterval)? {
|
|
guard debugWorkspaceSwitchId > 0, debugWorkspaceSwitchStartTime > 0 else { return nil }
|
|
return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime)
|
|
}
|
|
|
|
private static func debugShortWorkspaceId(_ id: UUID?) -> String {
|
|
guard let id else { return "nil" }
|
|
return String(id.uuidString.prefix(5))
|
|
}
|
|
|
|
private static func debugMsText(_ ms: Double) -> String {
|
|
String(format: "%.2fms", ms)
|
|
}
|
|
#endif
|
|
|
|
func selectTab(at index: Int) {
|
|
guard index >= 0 && index < tabs.count else { return }
|
|
selectedTabId = tabs[index].id
|
|
}
|
|
|
|
func selectLastTab() {
|
|
guard let lastTab = tabs.last else { return }
|
|
selectedTabId = lastTab.id
|
|
}
|
|
|
|
// MARK: - Surface Navigation
|
|
|
|
/// Select the next surface in the currently focused pane of the selected workspace
|
|
func selectNextSurface() {
|
|
selectedWorkspace?.selectNextSurface()
|
|
}
|
|
|
|
/// Select the previous surface in the currently focused pane of the selected workspace
|
|
func selectPreviousSurface() {
|
|
selectedWorkspace?.selectPreviousSurface()
|
|
}
|
|
|
|
/// Select a surface by index in the currently focused pane of the selected workspace
|
|
func selectSurface(at index: Int) {
|
|
selectedWorkspace?.selectSurface(at: index)
|
|
}
|
|
|
|
/// Select the last surface in the currently focused pane of the selected workspace
|
|
func selectLastSurface() {
|
|
selectedWorkspace?.selectLastSurface()
|
|
}
|
|
|
|
/// Create a new terminal surface in the focused pane of the selected workspace
|
|
func newSurface() {
|
|
// Cmd+T should always focus the newly created surface.
|
|
selectedWorkspace?.newTerminalSurfaceInFocusedPane(focus: true)
|
|
}
|
|
|
|
// MARK: - Split Creation
|
|
|
|
/// Create a new split in the current tab
|
|
func createSplit(direction: SplitDirection) {
|
|
guard let selectedTabId,
|
|
let tab = tabs.first(where: { $0.id == selectedTabId }),
|
|
let focusedPanelId = tab.focusedPanelId else { return }
|
|
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
|
|
}
|
|
|
|
// 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) -> UUID? {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
|
return tab.newTerminalSplit(
|
|
from: surfaceId,
|
|
orientation: direction.orientation,
|
|
insertFirst: direction.insertFirst
|
|
)?.id
|
|
}
|
|
|
|
/// Move focus in the specified direction
|
|
func moveSplitFocus(tabId: UUID, surfaceId: UUID, direction: NavigationDirection) -> Bool {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return false }
|
|
tab.moveFocus(direction: direction)
|
|
return true
|
|
}
|
|
|
|
/// Resize split - not directly supported by bonsplit, but we can adjust divider positions
|
|
func resizeSplit(tabId: UUID, surfaceId: UUID, direction: ResizeDirection, amount: UInt16) -> Bool {
|
|
// Bonsplit handles resize through its own divider dragging
|
|
// This is a no-op for now as bonsplit manages divider positions internally
|
|
return false
|
|
}
|
|
|
|
/// Equalize splits - not directly supported by bonsplit
|
|
func equalizeSplits(tabId: UUID) -> Bool {
|
|
// Bonsplit doesn't have a built-in equalize feature
|
|
// This would require manually setting all divider positions to 0.5
|
|
return false
|
|
}
|
|
|
|
/// Toggle zoom on a panel - bonsplit doesn't have zoom support
|
|
func toggleSplitZoom(tabId: UUID, surfaceId: UUID) -> Bool {
|
|
// Bonsplit doesn't have zoom support
|
|
return false
|
|
}
|
|
|
|
/// Close a surface/panel
|
|
func closeSurface(tabId: UUID, surfaceId: UUID) -> Bool {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return false }
|
|
// Guard against stale close callbacks (e.g. child-exit can trigger multiple actions).
|
|
// A stale callback must never affect unrelated panels/workspaces.
|
|
guard tab.panels[surfaceId] != nil,
|
|
tab.surfaceIdFromPanelId(surfaceId) != nil else { return false }
|
|
tab.closePanel(surfaceId)
|
|
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tabId, surfaceId: surfaceId)
|
|
return true
|
|
}
|
|
|
|
// MARK: - Browser Panel Operations
|
|
|
|
/// Create a new browser panel in a split
|
|
func newBrowserSplit(tabId: UUID, fromPanelId: UUID, orientation: SplitOrientation, url: URL? = nil) -> UUID? {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
|
return tab.newBrowserSplit(from: fromPanelId, orientation: orientation, url: url)?.id
|
|
}
|
|
|
|
/// Create a new browser surface in a pane
|
|
func newBrowserSurface(tabId: UUID, inPane paneId: PaneID, url: URL? = nil) -> UUID? {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
|
return tab.newBrowserSurface(inPane: paneId, url: url)?.id
|
|
}
|
|
|
|
/// Get a browser panel by ID
|
|
func browserPanel(tabId: UUID, panelId: UUID) -> BrowserPanel? {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
|
return tab.browserPanel(for: panelId)
|
|
}
|
|
|
|
/// Open a browser in the currently focused pane (as a new surface)
|
|
@discardableResult
|
|
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
|
|
guard let tabId = selectedTabId,
|
|
let tab = tabs.first(where: { $0.id == tabId }),
|
|
let focusedPaneId = tab.bonsplitController.focusedPaneId else { return nil }
|
|
let panel = tab.newBrowserSurface(
|
|
inPane: focusedPaneId,
|
|
url: url,
|
|
focus: true,
|
|
insertAtEnd: insertAtEnd
|
|
)
|
|
return panel?.id
|
|
}
|
|
|
|
/// Flash the currently focused panel so the user can visually confirm focus.
|
|
func triggerFocusFlash() {
|
|
guard let tab = selectedWorkspace,
|
|
let panelId = tab.focusedPanelId else { return }
|
|
tab.triggerFocusFlash(panelId: panelId)
|
|
}
|
|
|
|
/// Ensure AppKit first responder matches the currently focused terminal panel.
|
|
/// This keeps real keyboard events (including Ctrl+D) on the same panel as the
|
|
/// bonsplit focus indicator after rapid split topology changes.
|
|
func ensureFocusedTerminalFirstResponder() {
|
|
guard let tab = selectedWorkspace,
|
|
let panelId = tab.focusedPanelId,
|
|
let terminal = tab.terminalPanel(for: panelId) else { return }
|
|
terminal.hostedView.ensureFocus(for: tab.id, surfaceId: panelId)
|
|
}
|
|
|
|
/// Reconcile keyboard routing before terminal control shortcuts (e.g. Ctrl+D).
|
|
///
|
|
/// Source of truth for pane focus is bonsplit's focused pane + selected tab.
|
|
/// Keyboard delivery must converge AppKit first responder to that model state, not mutate
|
|
/// the model from whatever first responder happened to be during reparenting transitions.
|
|
func reconcileFocusedPanelFromFirstResponderForKeyboard() {
|
|
ensureFocusedTerminalFirstResponder()
|
|
}
|
|
|
|
/// Get a terminal panel by ID
|
|
func terminalPanel(tabId: UUID, panelId: UUID) -> TerminalPanel? {
|
|
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
|
|
return tab.terminalPanel(for: panelId)
|
|
}
|
|
|
|
/// Get the panel for a surface ID (terminal panels use surface ID as panel ID)
|
|
func surface(for tabId: UUID, surfaceId: UUID) -> TerminalSurface? {
|
|
terminalPanel(tabId: tabId, panelId: surfaceId)?.surface
|
|
}
|
|
|
|
#if DEBUG
|
|
private func setupUITestFocusShortcutsIfNeeded() {
|
|
guard !didSetupUITestFocusShortcuts else { return }
|
|
didSetupUITestFocusShortcuts = true
|
|
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_FOCUS_SHORTCUTS"] == "1" else { return }
|
|
|
|
// UI tests can't record arrow keys via the shortcut recorder. Use letter-based shortcuts
|
|
// so tests can reliably drive pane navigation without mouse clicks.
|
|
KeyboardShortcutSettings.setShortcut(
|
|
StoredShortcut(key: "h", command: true, shift: false, option: false, control: true),
|
|
for: .focusLeft
|
|
)
|
|
KeyboardShortcutSettings.setShortcut(
|
|
StoredShortcut(key: "l", command: true, shift: false, option: false, control: true),
|
|
for: .focusRight
|
|
)
|
|
KeyboardShortcutSettings.setShortcut(
|
|
StoredShortcut(key: "k", command: true, shift: false, option: false, control: true),
|
|
for: .focusUp
|
|
)
|
|
KeyboardShortcutSettings.setShortcut(
|
|
StoredShortcut(key: "j", command: true, shift: false, option: false, control: true),
|
|
for: .focusDown
|
|
)
|
|
}
|
|
|
|
private func setupSplitCloseRightUITestIfNeeded() {
|
|
guard !didSetupSplitCloseRightUITest else { return }
|
|
didSetupSplitCloseRightUITest = true
|
|
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SETUP"] == "1" else { return }
|
|
guard let path = env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATH"], !path.isEmpty else { return }
|
|
let visualMode = env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] == "1"
|
|
let shotsDir = (env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_SHOTS_DIR"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let visualIterations = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_ITERATIONS"] ?? "20").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 20
|
|
let burstFrames = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_BURST_FRAMES"] ?? "6").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 6
|
|
let closeDelayMs = Int((env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_CLOSE_DELAY_MS"] ?? "70").trimmingCharacters(in: .whitespacesAndNewlines)) ?? 70
|
|
let pattern = (env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_PATTERN"] ?? "close_right")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
guard let self else { return }
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
guard let tab = self.selectedWorkspace else {
|
|
self.writeSplitCloseRightTestData(["setupError": "Missing selected workspace"], at: path)
|
|
return
|
|
}
|
|
|
|
var readyTerminal: TerminalPanel?
|
|
for _ in 0..<20 {
|
|
if let terminal = tab.focusedTerminalPanel,
|
|
terminal.hostedView.window != nil,
|
|
terminal.surface.surface != nil {
|
|
readyTerminal = terminal
|
|
break
|
|
}
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
|
|
guard let terminal = readyTerminal else {
|
|
let maybeTerminal = tab.focusedTerminalPanel
|
|
self.writeSplitCloseRightTestData([
|
|
"preTerminalAttached": (maybeTerminal?.hostedView.window != nil) ? "1" : "0",
|
|
"preTerminalSurfaceNil": (maybeTerminal?.surface.surface == nil) ? "1" : "0",
|
|
"setupError": "Initial terminal not ready (not attached or surface nil)"
|
|
], at: path)
|
|
return
|
|
}
|
|
|
|
self.writeSplitCloseRightTestData([
|
|
"preTerminalAttached": "1",
|
|
"preTerminalSurfaceNil": terminal.surface.surface == nil ? "1" : "0"
|
|
], at: path)
|
|
|
|
guard let topLeftPanelId = tab.focusedPanelId else {
|
|
self.writeSplitCloseRightTestData(["setupError": "Missing initial focused panel"], at: path)
|
|
return
|
|
}
|
|
|
|
if visualMode {
|
|
// Visual repro mode: repeat the split/close sequence many times and write
|
|
// screenshots to `shotsDir`. This avoids relying on XCUITest to click hover-only
|
|
// close buttons, while still exercising the "close unfocused right tabs" path.
|
|
self.writeSplitCloseRightTestData([
|
|
"visualMode": "1",
|
|
"visualIterations": String(visualIterations),
|
|
"visualDone": "0"
|
|
], at: path)
|
|
|
|
await self.runSplitCloseRightVisualRepro(
|
|
tab: tab,
|
|
topLeftPanelId: topLeftPanelId,
|
|
path: path,
|
|
shotsDir: shotsDir,
|
|
iterations: max(1, min(visualIterations, 60)),
|
|
burstFrames: max(0, min(burstFrames, 80)),
|
|
closeDelayMs: max(0, min(closeDelayMs, 500)),
|
|
pattern: pattern
|
|
)
|
|
|
|
self.writeSplitCloseRightTestData(["visualDone": "1"], at: path)
|
|
return
|
|
}
|
|
|
|
// Layout goal: 2x2 grid (2 top, 2 bottom), then close both right panels.
|
|
// Order matters: split down first, then split right in each row (matches UI shortcut repro).
|
|
guard let bottomLeft = tab.newTerminalSplit(from: topLeftPanelId, orientation: .vertical) else {
|
|
self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-left split"], at: path)
|
|
return
|
|
}
|
|
guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else {
|
|
self.writeSplitCloseRightTestData(["setupError": "Failed to create bottom-right split"], at: path)
|
|
return
|
|
}
|
|
tab.focusPanel(topLeftPanelId)
|
|
guard let topRight = tab.newTerminalSplit(from: topLeftPanelId, orientation: .horizontal) else {
|
|
self.writeSplitCloseRightTestData(["setupError": "Failed to create top-right split"], at: path)
|
|
return
|
|
}
|
|
|
|
self.writeSplitCloseRightTestData([
|
|
"tabId": tab.id.uuidString,
|
|
"topLeftPanelId": topLeftPanelId.uuidString,
|
|
"bottomLeftPanelId": bottomLeft.id.uuidString,
|
|
"topRightPanelId": topRight.id.uuidString,
|
|
"bottomRightPanelId": bottomRight.id.uuidString,
|
|
"createdPaneCount": String(tab.bonsplitController.allPaneIds.count),
|
|
"createdPanelCount": String(tab.panels.count)
|
|
], at: path)
|
|
|
|
DebugUIEventCounters.resetEmptyPanelAppearCount()
|
|
|
|
// Close the two right panes via the same path as Cmd+W.
|
|
tab.focusPanel(topRight.id)
|
|
tab.closePanel(topRight.id, force: true)
|
|
tab.focusPanel(bottomRight.id)
|
|
tab.closePanel(bottomRight.id, force: true)
|
|
|
|
|
|
// Capture final state after Bonsplit/AppKit/Ghostty geometry reconciliation.
|
|
// We avoid sleep-based timing and converge over a few main-actor turns.
|
|
@MainActor func collectSplitCloseRightState() -> (data: [String: String], settled: Bool) {
|
|
let paneIds = tab.bonsplitController.allPaneIds
|
|
let bonsplitTabCount = tab.bonsplitController.allTabIds.count
|
|
let panelCount = tab.panels.count
|
|
|
|
var missingSelectedTabCount = 0
|
|
var missingPanelMappingCount = 0
|
|
var selectedTerminalCount = 0
|
|
var selectedTerminalAttachedCount = 0
|
|
var selectedTerminalZeroSizeCount = 0
|
|
var selectedTerminalSurfaceNilCount = 0
|
|
|
|
for paneId in paneIds {
|
|
guard let selected = tab.bonsplitController.selectedTab(inPane: paneId) else {
|
|
missingSelectedTabCount += 1
|
|
continue
|
|
}
|
|
guard let panel = tab.panel(for: selected.id) else {
|
|
missingPanelMappingCount += 1
|
|
continue
|
|
}
|
|
if let terminal = panel as? TerminalPanel {
|
|
selectedTerminalCount += 1
|
|
if terminal.hostedView.window != nil {
|
|
selectedTerminalAttachedCount += 1
|
|
}
|
|
let size = terminal.hostedView.bounds.size
|
|
if size.width < 5 || size.height < 5 {
|
|
selectedTerminalZeroSizeCount += 1
|
|
}
|
|
if terminal.surface.surface == nil {
|
|
selectedTerminalSurfaceNilCount += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
let settled =
|
|
paneIds.count == 2 &&
|
|
missingSelectedTabCount == 0 &&
|
|
missingPanelMappingCount == 0 &&
|
|
DebugUIEventCounters.emptyPanelAppearCount == 0 &&
|
|
selectedTerminalCount == 2 &&
|
|
selectedTerminalAttachedCount == 2 &&
|
|
selectedTerminalZeroSizeCount == 0 &&
|
|
selectedTerminalSurfaceNilCount == 0
|
|
|
|
return (
|
|
data: [
|
|
"finalPaneCount": String(paneIds.count),
|
|
"finalBonsplitTabCount": String(bonsplitTabCount),
|
|
"finalPanelCount": String(panelCount),
|
|
"missingSelectedTabCount": String(missingSelectedTabCount),
|
|
"missingPanelMappingCount": String(missingPanelMappingCount),
|
|
"emptyPanelAppearCount": String(DebugUIEventCounters.emptyPanelAppearCount),
|
|
"selectedTerminalCount": String(selectedTerminalCount),
|
|
"selectedTerminalAttachedCount": String(selectedTerminalAttachedCount),
|
|
"selectedTerminalZeroSizeCount": String(selectedTerminalZeroSizeCount),
|
|
"selectedTerminalSurfaceNilCount": String(selectedTerminalSurfaceNilCount),
|
|
],
|
|
settled: settled
|
|
)
|
|
}
|
|
@MainActor func reconcileVisibleTerminalGeometry() {
|
|
NSApp.windows.forEach { window in
|
|
window.contentView?.layoutSubtreeIfNeeded()
|
|
window.contentView?.displayIfNeeded()
|
|
}
|
|
for paneId in tab.bonsplitController.allPaneIds {
|
|
guard let selected = tab.bonsplitController.selectedTab(inPane: paneId),
|
|
let terminal = tab.panel(for: selected.id) as? TerminalPanel else {
|
|
continue
|
|
}
|
|
terminal.hostedView.reconcileGeometryNow()
|
|
terminal.surface.forceRefresh()
|
|
}
|
|
}
|
|
|
|
var finalState = collectSplitCloseRightState()
|
|
for attempt in 1...8 {
|
|
reconcileVisibleTerminalGeometry()
|
|
await Task.yield()
|
|
finalState = collectSplitCloseRightState()
|
|
var payload = finalState.data
|
|
payload["finalAttempt"] = String(attempt)
|
|
self.writeSplitCloseRightTestData(payload, at: path)
|
|
if finalState.settled {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func runSplitCloseRightVisualRepro(
|
|
tab: Workspace,
|
|
topLeftPanelId: UUID,
|
|
path: String,
|
|
shotsDir: String,
|
|
iterations: Int,
|
|
burstFrames: Int,
|
|
closeDelayMs: Int,
|
|
pattern: String
|
|
) async {
|
|
_ = shotsDir // legacy: screenshots removed in favor of IOSurface sampling
|
|
|
|
func sendText(_ panelId: UUID, _ text: String) {
|
|
guard let tp = tab.terminalPanel(for: panelId) else { return }
|
|
tp.surface.sendText(text)
|
|
}
|
|
|
|
// Sample a very top strip so the probe remains valid even after vertical expand/collapse.
|
|
// We pin marker text to row 1 before each close sequence.
|
|
let sampleCrop = CGRect(x: 0.04, y: 0.01, width: 0.92, height: 0.08)
|
|
|
|
for i in 1...iterations {
|
|
// Reset to a single pane: close everything except the top-left panel.
|
|
tab.focusPanel(topLeftPanelId)
|
|
let toClose = Array(tab.panels.keys).filter { $0 != topLeftPanelId }
|
|
for pid in toClose {
|
|
tab.closePanel(pid, force: true)
|
|
}
|
|
|
|
// Create the repro layout. Most patterns use a 2x2 grid, but keep a single-split
|
|
// variant for the exact "close right in a horizontal pair" user report.
|
|
let topLeftId = topLeftPanelId
|
|
let topRight: TerminalPanel
|
|
var bottomLeft: TerminalPanel?
|
|
var bottomRight: TerminalPanel?
|
|
|
|
switch pattern {
|
|
case "close_right_single":
|
|
guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else {
|
|
writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path)
|
|
return
|
|
}
|
|
topRight = tr
|
|
case "close_right_lrtd", "close_right_lrtd_bottom_first", "close_right_bottom_first", "close_right_lrtd_unfocused":
|
|
// User repro: split left/right first, then split top/down in each column.
|
|
guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else {
|
|
writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path)
|
|
return
|
|
}
|
|
guard let bl = tab.newTerminalSplit(from: topLeftId, orientation: .vertical) else {
|
|
writeSplitCloseRightTestData(["setupError": "Failed to split down from left (iteration \(i))"], at: path)
|
|
return
|
|
}
|
|
guard let br = tab.newTerminalSplit(from: tr.id, orientation: .vertical) else {
|
|
writeSplitCloseRightTestData(["setupError": "Failed to split down from right (iteration \(i))"], at: path)
|
|
return
|
|
}
|
|
topRight = tr
|
|
bottomLeft = bl
|
|
bottomRight = br
|
|
default:
|
|
// Default: split top/down first, then split left/right in each row.
|
|
guard let bl = tab.newTerminalSplit(from: topLeftId, orientation: .vertical) else {
|
|
writeSplitCloseRightTestData(["setupError": "Failed to split down from top-left (iteration \(i))"], at: path)
|
|
return
|
|
}
|
|
guard let br = tab.newTerminalSplit(from: bl.id, orientation: .horizontal) else {
|
|
writeSplitCloseRightTestData(["setupError": "Failed to split right from bottom-left (iteration \(i))"], at: path)
|
|
return
|
|
}
|
|
guard let tr = tab.newTerminalSplit(from: topLeftId, orientation: .horizontal) else {
|
|
writeSplitCloseRightTestData(["setupError": "Failed to split right from top-left (iteration \(i))"], at: path)
|
|
return
|
|
}
|
|
topRight = tr
|
|
bottomLeft = bl
|
|
bottomRight = br
|
|
}
|
|
|
|
// Let newly created surfaces attach before priming content, so sampled panes have
|
|
// stable non-blank text before the close timeline begins.
|
|
try? await Task.sleep(nanoseconds: 180_000_000)
|
|
|
|
// Fill left panes with visible content.
|
|
sendText(topLeftId, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo CMUX_SPLIT_TOPLEFT_\(i); done; printf '\\033[HCMUX_MARKER_TOPLEFT\\n'\r")
|
|
sendText(topRight.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo CMUX_SPLIT_TOPRIGHT_\(i); done; printf '\\033[HCMUX_MARKER_TOPRIGHT\\n'\r")
|
|
if let bottomLeft {
|
|
sendText(bottomLeft.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo CMUX_SPLIT_BOTTOMLEFT_\(i); done; printf '\\033[HCMUX_MARKER_BOTTOMLEFT\\n'\r")
|
|
}
|
|
if let bottomRight {
|
|
sendText(bottomRight.id, "printf '\\033[2J\\033[H'; for i in {1..200}; do echo CMUX_SPLIT_BOTTOMRIGHT_\(i); done; printf '\\033[HCMUX_MARKER_BOTTOMRIGHT\\n'\r")
|
|
}
|
|
// Give shell output a moment to paint before we start the close timeline.
|
|
try? await Task.sleep(nanoseconds: 180_000_000)
|
|
|
|
let desiredFrames = max(16, min(burstFrames, 60))
|
|
let closeFrame = min(6, max(1, desiredFrames / 4))
|
|
let delayFrames = max(0, Int((Double(max(0, closeDelayMs)) / 16.6667).rounded(.up)))
|
|
let secondCloseFrame = min(desiredFrames - 1, closeFrame + delayFrames)
|
|
|
|
var closeOrder = ""
|
|
let actions: [(frame: Int, action: () -> Void)] = {
|
|
switch pattern {
|
|
case "close_right_single":
|
|
closeOrder = "TR_ONLY"
|
|
return [
|
|
(frame: closeFrame, action: {
|
|
tab.focusPanel(topRight.id)
|
|
tab.closePanel(topRight.id, force: true)
|
|
}),
|
|
]
|
|
case "close_bottom":
|
|
guard let bottomRight, let bottomLeft else { return [] }
|
|
closeOrder = "BR_THEN_BL"
|
|
return [
|
|
(frame: closeFrame, action: {
|
|
tab.focusPanel(bottomRight.id)
|
|
tab.closePanel(bottomRight.id, force: true)
|
|
}),
|
|
(frame: secondCloseFrame, action: {
|
|
tab.focusPanel(bottomLeft.id)
|
|
tab.closePanel(bottomLeft.id, force: true)
|
|
}),
|
|
]
|
|
case "close_right_lrtd_bottom_first", "close_right_bottom_first":
|
|
guard let bottomRight else { return [] }
|
|
closeOrder = "BR_THEN_TR"
|
|
return [
|
|
(frame: closeFrame, action: {
|
|
tab.focusPanel(bottomRight.id)
|
|
tab.closePanel(bottomRight.id, force: true)
|
|
}),
|
|
(frame: secondCloseFrame, action: {
|
|
tab.focusPanel(topRight.id)
|
|
tab.closePanel(topRight.id, force: true)
|
|
}),
|
|
]
|
|
case "close_right_lrtd_unfocused":
|
|
guard let bottomRight else { return [] }
|
|
closeOrder = "TR_THEN_BR_UNFOCUSED"
|
|
return [
|
|
(frame: closeFrame, action: {
|
|
tab.closePanel(topRight.id, force: true)
|
|
}),
|
|
(frame: secondCloseFrame, action: {
|
|
tab.closePanel(bottomRight.id, force: true)
|
|
}),
|
|
]
|
|
default:
|
|
guard let bottomRight else { return [] }
|
|
closeOrder = "TR_THEN_BR"
|
|
return [
|
|
(frame: closeFrame, action: {
|
|
tab.focusPanel(topRight.id)
|
|
tab.closePanel(topRight.id, force: true)
|
|
}),
|
|
(frame: secondCloseFrame, action: {
|
|
tab.focusPanel(bottomRight.id)
|
|
tab.closePanel(bottomRight.id, force: true)
|
|
}),
|
|
]
|
|
}
|
|
}()
|
|
|
|
let targets: [(label: String, view: GhosttySurfaceScrollView)] = {
|
|
switch pattern {
|
|
case "close_right_single":
|
|
return [
|
|
("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView),
|
|
]
|
|
case "close_bottom":
|
|
return [
|
|
("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView),
|
|
("TR", topRight.surface.hostedView),
|
|
]
|
|
case "close_right_lrtd_bottom_first", "close_right_bottom_first":
|
|
return [
|
|
("TR", topRight.surface.hostedView),
|
|
("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView),
|
|
]
|
|
default:
|
|
guard let bottomLeft else { return [] }
|
|
return [
|
|
("TL", tab.terminalPanel(for: topLeftId)!.surface.hostedView),
|
|
("BL", bottomLeft.surface.hostedView),
|
|
]
|
|
}
|
|
}()
|
|
|
|
let result = await captureVsyncIOSurfaceTimeline(
|
|
frameCount: desiredFrames,
|
|
closeFrame: closeFrame,
|
|
crop: sampleCrop,
|
|
targets: targets,
|
|
actions: actions
|
|
)
|
|
|
|
let paneStateTrace: String = {
|
|
tab.bonsplitController.allPaneIds.map { paneId in
|
|
let tabs = tab.bonsplitController.tabs(inPane: paneId)
|
|
let selected = tab.bonsplitController.selectedTab(inPane: paneId)
|
|
let selectedId = selected.map { String(describing: $0.id) } ?? "nil"
|
|
let selectedPanelId = selected.flatMap { tab.panelIdFromSurfaceId($0.id) }
|
|
let selectedPanelLive: String = {
|
|
guard let selected else { return "0" }
|
|
return tab.panel(for: selected.id) != nil ? "1" : "0"
|
|
}()
|
|
let mappedCount = tabs.filter { tab.panelIdFromSurfaceId($0.id) != nil }.count
|
|
let selectedPanel = selectedPanelId?.uuidString.prefix(8) ?? "nil"
|
|
return "pane=\(paneId.id.uuidString.prefix(8)):tabs=\(tabs.count):mapped=\(mappedCount):selected=\(selectedId.prefix(8)):selectedPanel=\(selectedPanel):selectedLive=\(selectedPanelLive)"
|
|
}.joined(separator: ";")
|
|
}()
|
|
|
|
writeSplitCloseRightTestData([
|
|
"pattern": pattern,
|
|
"iteration": String(i),
|
|
"closeDelayMs": String(closeDelayMs),
|
|
"closeDelayFrames": String(delayFrames),
|
|
"closeOrder": closeOrder,
|
|
"timelineFrameCount": String(desiredFrames),
|
|
"timelineCloseFrame": String(closeFrame),
|
|
"timelineSecondCloseFrame": String(secondCloseFrame),
|
|
"timelineFirstBlank": result.firstBlank.map { "\($0.label)@\($0.frame)" } ?? "",
|
|
"timelineFirstSizeMismatch": result.firstSizeMismatch.map { "\($0.label)@\($0.frame):ios=\($0.ios):exp=\($0.expected)" } ?? "",
|
|
"timelineTrace": result.trace.joined(separator: "|"),
|
|
"timelinePaneState": paneStateTrace,
|
|
"visualLastIteration": String(i),
|
|
], at: path)
|
|
|
|
if let firstBlank = result.firstBlank {
|
|
writeSplitCloseRightTestData([
|
|
"blankFrameSeen": "1",
|
|
"blankObservedIteration": String(i),
|
|
"blankObservedAt": "\(firstBlank.label)@\(firstBlank.frame)"
|
|
], at: path)
|
|
return
|
|
}
|
|
|
|
if let firstMismatch = result.firstSizeMismatch {
|
|
writeSplitCloseRightTestData([
|
|
"sizeMismatchSeen": "1",
|
|
"sizeMismatchObservedIteration": String(i),
|
|
"sizeMismatchObservedAt": "\(firstMismatch.label)@\(firstMismatch.frame):ios=\(firstMismatch.ios):exp=\(firstMismatch.expected)"
|
|
], at: path)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func captureVsyncIOSurfaceTimeline(
|
|
frameCount: Int,
|
|
closeFrame: Int,
|
|
crop: CGRect,
|
|
targets: [(label: String, view: GhosttySurfaceScrollView)],
|
|
actions: [(frame: Int, action: () -> Void)] = []
|
|
) async -> (firstBlank: (label: String, frame: Int)?, firstSizeMismatch: (label: String, frame: Int, ios: String, expected: String)?, trace: [String]) {
|
|
guard frameCount > 0 else { return (nil, nil, []) }
|
|
|
|
let st = VsyncIOSurfaceTimelineState(frameCount: frameCount, closeFrame: closeFrame)
|
|
st.scheduledActions = actions.sorted(by: { $0.frame < $1.frame })
|
|
st.nextActionIndex = 0
|
|
st.targets = targets.map { t in
|
|
VsyncIOSurfaceTimelineState.Target(label: t.label, sample: { @MainActor in
|
|
t.view.debugSampleIOSurface(normalizedCrop: crop)
|
|
})
|
|
}
|
|
|
|
let unmanaged = Unmanaged.passRetained(st)
|
|
let ctx = unmanaged.toOpaque()
|
|
|
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
st.continuation = cont
|
|
var link: CVDisplayLink?
|
|
CVDisplayLinkCreateWithActiveCGDisplays(&link)
|
|
guard let link else {
|
|
st.finish()
|
|
Unmanaged<VsyncIOSurfaceTimelineState>.fromOpaque(ctx).release()
|
|
return
|
|
}
|
|
st.link = link
|
|
|
|
CVDisplayLinkSetOutputCallback(link, cmuxVsyncIOSurfaceTimelineCallback, ctx)
|
|
CVDisplayLinkStart(link)
|
|
}
|
|
|
|
return (st.firstBlank, st.firstSizeMismatch, st.trace)
|
|
}
|
|
|
|
private func writeSplitCloseRightTestData(_ updates: [String: String], at path: String) {
|
|
var payload = loadSplitCloseRightTestData(at: path)
|
|
for (key, value) in updates {
|
|
payload[key] = value
|
|
}
|
|
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
|
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
|
|
}
|
|
|
|
private func loadSplitCloseRightTestData(at path: String) -> [String: String] {
|
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
|
|
return [:]
|
|
}
|
|
return object
|
|
}
|
|
|
|
private func setupChildExitSplitUITestIfNeeded() {
|
|
guard !didSetupChildExitSplitUITest else { return }
|
|
didSetupChildExitSplitUITest = true
|
|
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_CHILD_EXIT_SPLIT_SETUP"] == "1" else { return }
|
|
guard let path = env["CMUX_UI_TEST_CHILD_EXIT_SPLIT_PATH"], !path.isEmpty else { return }
|
|
let requestedIterations = Int(env["CMUX_UI_TEST_CHILD_EXIT_SPLIT_ITERATIONS"] ?? "1") ?? 1
|
|
let iterations = max(1, min(requestedIterations, 20))
|
|
|
|
func write(_ updates: [String: String]) {
|
|
var payload: [String: String] = {
|
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
|
|
return [:]
|
|
}
|
|
return obj
|
|
}()
|
|
for (k, v) in updates { payload[k] = v }
|
|
guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
|
try? out.write(to: URL(fileURLWithPath: path), options: .atomic)
|
|
}
|
|
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
// Small delay so the initial window/panel has completed first layout.
|
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
|
|
guard let tab = self.selectedWorkspace else {
|
|
write(["setupError": "Missing selected workspace", "done": "1"])
|
|
return
|
|
}
|
|
write([
|
|
"requestedIterations": String(requestedIterations),
|
|
"iterations": String(iterations),
|
|
"workspaceCountBefore": String(self.tabs.count),
|
|
"panelCountBefore": String(tab.panels.count),
|
|
"done": "0",
|
|
])
|
|
|
|
var completedIterations = 0
|
|
var timedOut = false
|
|
var closedWorkspace = false
|
|
|
|
for i in 1...iterations {
|
|
guard self.tabs.contains(where: { $0.id == tab.id }) else {
|
|
closedWorkspace = true
|
|
break
|
|
}
|
|
|
|
guard let leftPanelId = tab.focusedPanelId ?? tab.panels.keys.first else {
|
|
write(["setupError": "Missing focused panel before iteration \(i)", "done": "1"])
|
|
return
|
|
}
|
|
|
|
// Start each iteration from a deterministic 1x1 workspace.
|
|
if tab.panels.count > 1 {
|
|
for panelId in tab.panels.keys where panelId != leftPanelId {
|
|
tab.closePanel(panelId, force: true)
|
|
}
|
|
try? await Task.sleep(nanoseconds: 80_000_000)
|
|
}
|
|
|
|
guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
|
write(["setupError": "Failed to create right split at iteration \(i)", "done": "1"])
|
|
return
|
|
}
|
|
|
|
write([
|
|
"iteration": String(i),
|
|
"leftPanelId": leftPanelId.uuidString,
|
|
"rightPanelId": rightPanel.id.uuidString,
|
|
])
|
|
|
|
tab.focusPanel(rightPanel.id)
|
|
// Wait for the split terminal surface to be attached before sending exit.
|
|
// Without this, very early writes can be dropped during initial surface creation.
|
|
let readyDeadline = Date().addingTimeInterval(2.0)
|
|
while Date() < readyDeadline {
|
|
let attached = rightPanel.hostedView.window != nil
|
|
let hasSurface = rightPanel.surface.surface != nil
|
|
if attached && hasSurface { break }
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
// Use an explicit shell exit command for deterministic child-exit behavior across
|
|
// startup timing variance; this still exercises the same SHOW_CHILD_EXITED path.
|
|
rightPanel.surface.sendText("exit\r")
|
|
|
|
// Wait for the right panel to close.
|
|
let closed = await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
var cancellable: AnyCancellable?
|
|
var resolved = false
|
|
|
|
func finish(_ value: Bool) {
|
|
guard !resolved else { return }
|
|
resolved = true
|
|
cancellable?.cancel()
|
|
cont.resume(returning: value)
|
|
}
|
|
|
|
cancellable = tab.$panels
|
|
.map { $0.count }
|
|
.removeDuplicates()
|
|
.sink { count in
|
|
if count == 1 {
|
|
finish(true)
|
|
}
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) {
|
|
finish(false)
|
|
}
|
|
}
|
|
|
|
if !closed {
|
|
timedOut = true
|
|
write(["timedOutIteration": String(i)])
|
|
break
|
|
}
|
|
|
|
if !self.tabs.contains(where: { $0.id == tab.id }) {
|
|
closedWorkspace = true
|
|
write(["closedWorkspaceIteration": String(i)])
|
|
break
|
|
}
|
|
|
|
completedIterations = i
|
|
}
|
|
|
|
let workspaceStillOpen = self.tabs.contains(where: { $0.id == tab.id })
|
|
let effectiveClosedWorkspace = closedWorkspace || !workspaceStillOpen
|
|
|
|
write([
|
|
"workspaceCountAfter": String(self.tabs.count),
|
|
"panelCountAfter": String(tab.panels.count),
|
|
"workspaceStillOpen": workspaceStillOpen ? "1" : "0",
|
|
"closedWorkspace": effectiveClosedWorkspace ? "1" : "0",
|
|
"timedOut": timedOut ? "1" : "0",
|
|
"completedIterations": String(completedIterations),
|
|
"done": "1",
|
|
])
|
|
}
|
|
}
|
|
|
|
private func setupChildExitKeyboardUITestIfNeeded() {
|
|
guard !didSetupChildExitKeyboardUITest else { return }
|
|
didSetupChildExitKeyboardUITest = true
|
|
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1" else { return }
|
|
guard let path = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], !path.isEmpty else { return }
|
|
let autoTrigger = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] == "1"
|
|
let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1"
|
|
let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let expectedPanelsAfter = max(
|
|
1,
|
|
Int((env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] ?? "1")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
) ?? 1
|
|
)
|
|
|
|
func write(_ updates: [String: String]) {
|
|
var payload: [String: String] = {
|
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
|
|
return [:]
|
|
}
|
|
return obj
|
|
}()
|
|
for (k, v) in updates { payload[k] = v }
|
|
guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
|
try? out.write(to: URL(fileURLWithPath: path), options: .atomic)
|
|
}
|
|
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
|
|
guard let tab = self.selectedWorkspace else {
|
|
write(["setupError": "Missing selected workspace", "done": "1"])
|
|
return
|
|
}
|
|
guard let leftPanelId = tab.focusedPanelId else {
|
|
write(["setupError": "Missing initial focused panel", "done": "1"])
|
|
return
|
|
}
|
|
guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
|
write(["setupError": "Failed to create right split", "done": "1"])
|
|
return
|
|
}
|
|
|
|
var bottomLeftPanelId = ""
|
|
let topRightPanelId = rightPanel.id.uuidString
|
|
var bottomRightPanelId = ""
|
|
var exitPanelId = rightPanel.id
|
|
|
|
if layout == "lr_left_vertical" {
|
|
guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else {
|
|
write(["setupError": "Failed to create bottom-left split", "done": "1"])
|
|
return
|
|
}
|
|
bottomLeftPanelId = bottomLeft.id.uuidString
|
|
} else if layout == "lrtd_close_right_then_exit_top_left" {
|
|
guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else {
|
|
write(["setupError": "Failed to create bottom-left split", "done": "1"])
|
|
return
|
|
}
|
|
guard let bottomRight = tab.newTerminalSplit(from: rightPanel.id, orientation: .vertical) else {
|
|
write(["setupError": "Failed to create bottom-right split", "done": "1"])
|
|
return
|
|
}
|
|
bottomLeftPanelId = bottomLeft.id.uuidString
|
|
bottomRightPanelId = bottomRight.id.uuidString
|
|
|
|
// Repro flow: with a 2x2 (left/right then top/down), close both right panes,
|
|
// then trigger Ctrl+D in top-left.
|
|
tab.focusPanel(rightPanel.id)
|
|
tab.closePanel(rightPanel.id, force: true)
|
|
tab.focusPanel(bottomRight.id)
|
|
tab.closePanel(bottomRight.id, force: true)
|
|
exitPanelId = leftPanelId
|
|
|
|
let closeDeadline = Date().addingTimeInterval(2.0)
|
|
while Date() < closeDeadline {
|
|
if tab.panels.count == 2 { break }
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
if tab.panels.count != 2 {
|
|
write([
|
|
"setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)",
|
|
"done": "1",
|
|
])
|
|
return
|
|
}
|
|
} else if layout == "tdlr_close_bottom_then_exit_top_left" {
|
|
// Alternate repro flow:
|
|
// 1) split top/down
|
|
// 2) split left/right for each row (2x2)
|
|
// 3) close both bottom panes
|
|
// 4) trigger Ctrl+D in top-left
|
|
guard let bottomLeft = tab.newTerminalSplit(from: leftPanelId, orientation: .vertical) else {
|
|
write(["setupError": "Failed to create bottom-left split", "done": "1"])
|
|
return
|
|
}
|
|
guard let topRight = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
|
write(["setupError": "Failed to create top-right split", "done": "1"])
|
|
return
|
|
}
|
|
guard let bottomRight = tab.newTerminalSplit(from: bottomLeft.id, orientation: .horizontal) else {
|
|
write(["setupError": "Failed to create bottom-right split", "done": "1"])
|
|
return
|
|
}
|
|
bottomLeftPanelId = bottomLeft.id.uuidString
|
|
bottomRightPanelId = bottomRight.id.uuidString
|
|
|
|
// Close every pane except the top row; do it one-by-one and wait for model convergence.
|
|
let keepPanels: Set<UUID> = [leftPanelId, topRight.id]
|
|
for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) {
|
|
tab.focusPanel(panelId)
|
|
tab.closePanel(panelId, force: true)
|
|
let deadline = Date().addingTimeInterval(1.0)
|
|
while Date() < deadline {
|
|
if tab.panels[panelId] == nil { break }
|
|
try? await Task.sleep(nanoseconds: 25_000_000)
|
|
}
|
|
if tab.panels[panelId] != nil {
|
|
write([
|
|
"setupError": "Failed to close bottom pane \(panelId.uuidString)",
|
|
"done": "1",
|
|
])
|
|
return
|
|
}
|
|
}
|
|
exitPanelId = leftPanelId
|
|
|
|
let closeDeadline = Date().addingTimeInterval(2.0)
|
|
while Date() < closeDeadline {
|
|
if tab.panels.count == 2 { break }
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
if tab.panels.count != 2 {
|
|
write([
|
|
"setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)",
|
|
"done": "1",
|
|
])
|
|
return
|
|
}
|
|
}
|
|
|
|
tab.focusPanel(exitPanelId)
|
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
|
|
let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? ""
|
|
let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in
|
|
guard let terminal = panel as? TerminalPanel else { return nil }
|
|
return terminal.hostedView.isSurfaceViewFirstResponder() ? panelId : nil
|
|
}.first?.uuidString ?? ""
|
|
|
|
write([
|
|
"workspaceId": tab.id.uuidString,
|
|
"leftPanelId": leftPanelId.uuidString,
|
|
"rightPanelId": rightPanel.id.uuidString,
|
|
"topRightPanelId": topRightPanelId,
|
|
"bottomLeftPanelId": bottomLeftPanelId,
|
|
"bottomRightPanelId": bottomRightPanelId,
|
|
"exitPanelId": exitPanelId.uuidString,
|
|
"panelCountBeforeCtrlD": String(tab.panels.count),
|
|
"layout": layout,
|
|
"expectedPanelsAfter": String(expectedPanelsAfter),
|
|
"focusedPanelBefore": focusedPanelBefore,
|
|
"firstResponderPanelBefore": firstResponderPanelBefore,
|
|
"ready": "1",
|
|
"done": "0",
|
|
])
|
|
|
|
var finished = false
|
|
var timeoutWork: DispatchWorkItem?
|
|
|
|
@MainActor
|
|
func finish(_ updates: [String: String]) {
|
|
guard !finished else { return }
|
|
finished = true
|
|
timeoutWork?.cancel()
|
|
write(updates.merging(["done": "1"], uniquingKeysWith: { _, new in new }))
|
|
self.uiTestCancellables.removeAll()
|
|
}
|
|
|
|
tab.$panels
|
|
.map { $0.count }
|
|
.removeDuplicates()
|
|
.sink { [weak self, weak tab] count in
|
|
Task { @MainActor in
|
|
guard let self, let tab else { return }
|
|
if count == expectedPanelsAfter {
|
|
// Require the post-exit state to be stable for a short window so
|
|
// we catch "close looked correct, then workspace vanished" races.
|
|
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
|
guard tab.panels.count == expectedPanelsAfter else { return }
|
|
|
|
let firstResponderPanelAfter = tab.panels.compactMap { (panelId, panel) -> UUID? in
|
|
guard let terminal = panel as? TerminalPanel else { return nil }
|
|
return terminal.hostedView.isSurfaceViewFirstResponder() ? panelId : nil
|
|
}.first?.uuidString ?? ""
|
|
|
|
finish([
|
|
"workspaceCountAfter": String(self.tabs.count),
|
|
"panelCountAfter": String(tab.panels.count),
|
|
"closedWorkspace": self.tabs.contains(where: { $0.id == tab.id }) ? "0" : "1",
|
|
"focusedPanelAfter": tab.focusedPanelId?.uuidString ?? "",
|
|
"firstResponderPanelAfter": firstResponderPanelAfter,
|
|
])
|
|
}
|
|
}
|
|
}
|
|
.store(in: &uiTestCancellables)
|
|
|
|
$tabs
|
|
.map { $0.contains(where: { $0.id == tab.id }) }
|
|
.removeDuplicates()
|
|
.sink { alive in
|
|
Task { @MainActor in
|
|
if !alive {
|
|
finish([
|
|
"workspaceCountAfter": "0",
|
|
"panelCountAfter": "0",
|
|
"closedWorkspace": "1",
|
|
])
|
|
}
|
|
}
|
|
}
|
|
.store(in: &uiTestCancellables)
|
|
|
|
let work = DispatchWorkItem {
|
|
finish([
|
|
"workspaceCountAfter": String(self.tabs.count),
|
|
"panelCountAfter": String(tab.panels.count),
|
|
"closedWorkspace": self.tabs.contains(where: { $0.id == tab.id }) ? "0" : "1",
|
|
"timedOut": "1",
|
|
])
|
|
}
|
|
timeoutWork = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 8.0, execute: work)
|
|
|
|
if autoTrigger {
|
|
Task { @MainActor [weak tab] in
|
|
guard let tab else { return }
|
|
write(["autoTriggerStarted": "1"])
|
|
|
|
if triggerMode == "runtime_close_callback" {
|
|
write(["autoTriggerMode": "runtime_close_callback"])
|
|
self.closePanelAfterChildExited(tabId: tab.id, surfaceId: exitPanelId)
|
|
return
|
|
}
|
|
|
|
// Wait for the target panel to be fully attached after split churn.
|
|
let readyDeadline = Date().addingTimeInterval(2.0)
|
|
var attachedBeforeTrigger = false
|
|
var hasSurfaceBeforeTrigger = false
|
|
while Date() < readyDeadline {
|
|
guard let panel = tab.terminalPanel(for: exitPanelId) else {
|
|
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
|
|
return
|
|
}
|
|
attachedBeforeTrigger = panel.hostedView.window != nil
|
|
hasSurfaceBeforeTrigger = panel.surface.surface != nil
|
|
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
|
|
break
|
|
}
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
write([
|
|
"exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0",
|
|
"exitPanelHasSurfaceBeforeTrigger": hasSurfaceBeforeTrigger ? "1" : "0",
|
|
])
|
|
|
|
guard let panel = tab.terminalPanel(for: exitPanelId) else {
|
|
write(["autoTriggerError": "missingExitPanelAtTrigger"])
|
|
return
|
|
}
|
|
// Exercise the real key path (ghostty_surface_key for Ctrl+D).
|
|
if panel.hostedView.sendSyntheticCtrlDForUITest() {
|
|
write(["autoTriggerSentCtrlDKey1": "1"])
|
|
} else {
|
|
write([
|
|
"autoTriggerCtrlDKeyUnavailable": "1",
|
|
"autoTriggerError": "ctrlDKeyUnavailable",
|
|
])
|
|
return
|
|
}
|
|
|
|
// In strict mode, never mask routing bugs with fallback writes.
|
|
if strictKeyOnly {
|
|
write(["autoTriggerMode": "strict_ctrl_d"])
|
|
return
|
|
}
|
|
|
|
// Non-strict mode keeps one additional Ctrl+D retry for startup timing variance.
|
|
try? await Task.sleep(nanoseconds: 450_000_000)
|
|
if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest() {
|
|
write(["autoTriggerSentCtrlDKey2": "1"])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Direction Types for Backwards Compatibility
|
|
|
|
/// Split direction for backwards compatibility with old API
|
|
enum SplitDirection {
|
|
case left, right, up, down
|
|
|
|
var isHorizontal: Bool {
|
|
self == .left || self == .right
|
|
}
|
|
|
|
var orientation: SplitOrientation {
|
|
isHorizontal ? .horizontal : .vertical
|
|
}
|
|
|
|
/// If true, insert the new pane on the "first" side (left/top).
|
|
/// If false, insert on the "second" side (right/bottom).
|
|
var insertFirst: Bool {
|
|
self == .left || self == .up
|
|
}
|
|
}
|
|
|
|
/// Resize direction for backwards compatibility
|
|
enum ResizeDirection {
|
|
case left, right, up, down
|
|
}
|
|
|
|
extension Notification.Name {
|
|
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
|
|
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
|
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
|
static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface")
|
|
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
|
|
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
|
|
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
|
|
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
|
|
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
|
|
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick")
|
|
static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink")
|
|
}
|