From 947263b7fdba8df3cce8e50423cfd1e94a38229a Mon Sep 17 00:00:00 2001 From: Gale W Date: Sun, 22 Mar 2026 22:28:33 -0400 Subject: [PATCH 1/4] Fix workspace and pane geometry churn --- Sources/AppDelegate.swift | 511 +++++-------- Sources/TerminalWindowPortal.swift | 505 ++++++++++++- Sources/Workspace.swift | 1134 +++++++++++++++------------- 3 files changed, 1285 insertions(+), 865 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0aa385ef8446..db3c22652598 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9,30 +9,6 @@ import Combine import ObjectiveC.runtime import Darwin -final class MainWindowHostingView: NSHostingView { - private let zeroSafeAreaLayoutGuide = NSLayoutGuide() - - override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } - override var safeAreaRect: NSRect { bounds } - override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } - - required init(rootView: Content) { - super.init(rootView: rootView) - addLayoutGuide(zeroSafeAreaLayoutGuide) - NSLayoutConstraint.activate([ - zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), - zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), - zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), - zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - private enum CmuxThemeNotifications { static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config") } @@ -427,7 +403,6 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { case cursor case finder case ghostty - case intellij case iterm2 case terminal case tower @@ -476,8 +451,6 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { return String(localized: "menu.openInFinder", defaultValue: "Open Current Directory in Finder") case .ghostty: return String(localized: "menu.openInGhostty", defaultValue: "Open Current Directory in Ghostty") - case .intellij: - return String(localized: "menu.openInIntelliJ", defaultValue: "Open Current Directory in IntelliJ IDEA") case .iterm2: return String(localized: "menu.openInITerm2", defaultValue: "Open Current Directory in iTerm2") case .terminal: @@ -512,8 +485,6 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { return common + ["finder", "file", "manager", "reveal"] case .ghostty: return common + ["ghostty", "terminal", "shell"] - case .intellij: - return common + ["intellij", "idea", "jetbrains"] case .iterm2: return common + ["iterm", "iterm2", "terminal", "shell"] case .terminal: @@ -607,8 +578,6 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable { return ["/System/Library/CoreServices/Finder.app"] case .ghostty: return ["/Applications/Ghostty.app"] - case .intellij: - return ["/Applications/IntelliJ IDEA.app"] case .iterm2: return [ "/Applications/iTerm.app", @@ -1114,9 +1083,9 @@ final class ServeWebOutputCollector { } enum WorkspaceShortcutMapper { - /// Maps numbered workspace shortcuts to a zero-based workspace index. - /// 1...8 target fixed indices; 9 always targets the last workspace. - static func workspaceIndex(forDigit digit: Int, workspaceCount: Int) -> Int? { + /// Maps Cmd+digit workspace shortcuts to a zero-based workspace index. + /// Cmd+1...Cmd+8 target fixed indices; Cmd+9 always targets the last workspace. + static func workspaceIndex(forCommandDigit digit: Int, workspaceCount: Int) -> Int? { guard workspaceCount > 0 else { return nil } guard (1...9).contains(digit) else { return nil } @@ -1128,12 +1097,12 @@ enum WorkspaceShortcutMapper { return index < workspaceCount ? index : nil } - /// Returns the primary digit badge to display for a workspace row. + /// Returns the primary Cmd+digit badge to display for a workspace row. /// Picks the lowest digit that maps to that row index. - static func digitForWorkspace(at index: Int, workspaceCount: Int) -> Int? { + static func commandDigitForWorkspace(at index: Int, workspaceCount: Int) -> Int? { guard index >= 0 && index < workspaceCount else { return nil } for digit in 1...9 { - if workspaceIndex(forDigit: digit, workspaceCount: workspaceCount) == index { + if workspaceIndex(forCommandDigit: digit, workspaceCount: workspaceCount) == index { return digit } } @@ -1644,7 +1613,9 @@ func shouldHandleCommandPaletteShortcutEvent( ) -> Bool { guard let paletteWindow else { return false } if let eventWindow = event.window { - return eventWindow === paletteWindow + if eventWindow === paletteWindow { + return true + } } let eventWindowNumber = event.windowNumber if eventWindowNumber > 0 { @@ -1921,7 +1892,7 @@ func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation { - nonisolated(unsafe) static var shared: AppDelegate? + static var shared: AppDelegate? private static let cachedIsRunningUnderXCTest = detectRunningUnderXCTest(ProcessInfo.processInfo.environment) @@ -2014,6 +1985,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var ghosttyGotoSplitRightShortcut: StoredShortcut? private var ghosttyGotoSplitUpShortcut: StoredShortcut? private var ghosttyGotoSplitDownShortcut: StoredShortcut? +#if DEBUG + private var uiTestTerminalGeometryTraceCounters: [String: Int] = [:] + private var uiTestTerminalGeometryTraceEvents: [String] = [] + private var uiTestTerminalGeometryPendingDetachedPanels: Set = [] + private var uiTestTerminalGeometryPendingCrossWindowHostedViews: Set = [] +#endif private var browserAddressBarFocusedPanelId: UUID? private var browserOmnibarRepeatStartWorkItem: DispatchWorkItem? private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? @@ -2072,8 +2049,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)? private var jumpUnreadFocusObserver: NSObjectProtocol? private var didSetupGotoSplitUITest = false - private var didSetupBonsplitTabDragUITest = false - private var bonsplitTabDragUITestRecorder: DispatchSourceTimer? private var gotoSplitUITestObservers: [NSObjectProtocol] = [] private var didSetupMultiWindowNotificationsUITest = false private var didSetupDisplayResolutionUITestDiagnostics = false @@ -2089,6 +2064,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let isFirstResponder: Bool } var debugCloseMainWindowConfirmationHandler: ((NSWindow) -> Bool)? + var debugClosePanelConfirmationHandler: ((TabID) -> Bool)? // Keep debug-only windows alive when tests intentionally inject key mismatches. private var debugDetachedContextWindows: [NSWindow] = [] @@ -2446,6 +2422,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } appendUITestRenderDiagnosticsIfNeeded(&payload, environment: env) appendUITestSocketDiagnosticsIfNeeded(&payload, environment: env) + appendUITestTerminalGeometryTraceIfNeeded(&payload, environment: env) guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } try? data.write(to: URL(fileURLWithPath: path), options: .atomic) @@ -2564,6 +2541,134 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } + private func isUITestTerminalGeometryTraceEnabled(_ env: [String: String]? = nil) -> Bool { + let environment = env ?? ProcessInfo.processInfo.environment + guard environment["CMUX_UI_TEST_TERMINAL_GEOMETRY_TRACE"] == "1" else { return false } + guard let path = environment["CMUX_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return false } + return true + } + + private func appendUITestTerminalGeometryTraceIfNeeded( + _ payload: inout [String: String], + environment env: [String: String] + ) { + guard isUITestTerminalGeometryTraceEnabled(env) else { return } + for (key, value) in currentUITestTerminalGeometryTracePayload() { + payload[key] = value + } + } + + func recordUITestTerminalGeometryEvent(kind: String, fields: [String: String] = [:]) { + let env = ProcessInfo.processInfo.environment + guard isUITestTerminalGeometryTraceEnabled(env) else { return } + + uiTestTerminalGeometryTraceCounters["eventCount", default: 0] += 1 + + if let panelId = fields["panelId"], !panelId.isEmpty { + switch kind { + case "visibleDetached", "transientPortalRemount": + uiTestTerminalGeometryPendingDetachedPanels.insert(panelId) + case "reattachRecovered": + uiTestTerminalGeometryPendingDetachedPanels.remove(panelId) + default: + break + } + } + + if let hostedId = fields["hostedId"], !hostedId.isEmpty { + switch kind { + case "crossWindowRebindStart": + uiTestTerminalGeometryPendingCrossWindowHostedViews.insert(hostedId) + case "crossWindowRebindRecovered": + uiTestTerminalGeometryPendingCrossWindowHostedViews.remove(hostedId) + default: + break + } + } + + switch kind { + case "visibleDetached": + uiTestTerminalGeometryTraceCounters["detachedVisiblePasses", default: 0] += 1 + case "transientPortalRemount": + uiTestTerminalGeometryTraceCounters["transientPortalRemountPasses", default: 0] += 1 + uiTestTerminalGeometryTraceCounters["reattachRequests", default: 0] += 1 + case "reattachRequested": + uiTestTerminalGeometryTraceCounters["reattachRequests", default: 0] += 1 + case "reattachRecovered": + uiTestTerminalGeometryTraceCounters["reattachRecoveredPasses", default: 0] += 1 + case "fullSyncTransient": + uiTestTerminalGeometryTraceCounters["fullSyncTransientPasses", default: 0] += 1 + case "fullSyncSettled": + uiTestTerminalGeometryTraceCounters["fullSyncSettledPasses", default: 0] += 1 + case "crossWindowRebindStart": + uiTestTerminalGeometryTraceCounters["crossWindowRebindStarts", default: 0] += 1 + case "crossWindowRebindRecovered": + uiTestTerminalGeometryTraceCounters["crossWindowRebindRecoveredPasses", default: 0] += 1 + default: + break + } + + var eventFields = ["kind=\(kind)"] + for key in fields.keys.sorted() { + guard let value = fields[key], !value.isEmpty else { continue } + eventFields.append("\(key)=\(value)") + } + uiTestTerminalGeometryTraceEvents.append(eventFields.joined(separator: " ")) + if uiTestTerminalGeometryTraceEvents.count > 16 { + uiTestTerminalGeometryTraceEvents.removeFirst(uiTestTerminalGeometryTraceEvents.count - 16) + } + + persistUITestTerminalGeometryTraceIfNeeded() + } + + private func currentUITestTerminalGeometryTracePayload() -> [String: String] { + let detachedVisiblePasses = uiTestTerminalGeometryTraceCounters["detachedVisiblePasses", default: 0] + let transientPortalRemountPasses = uiTestTerminalGeometryTraceCounters["transientPortalRemountPasses", default: 0] + let reattachRequests = uiTestTerminalGeometryTraceCounters["reattachRequests", default: 0] + let reattachRecoveredPasses = uiTestTerminalGeometryTraceCounters["reattachRecoveredPasses", default: 0] + let fullSyncTransientPasses = uiTestTerminalGeometryTraceCounters["fullSyncTransientPasses", default: 0] + let fullSyncSettledPasses = uiTestTerminalGeometryTraceCounters["fullSyncSettledPasses", default: 0] + let crossWindowRebindStarts = uiTestTerminalGeometryTraceCounters["crossWindowRebindStarts", default: 0] + let crossWindowRebindRecoveredPasses = uiTestTerminalGeometryTraceCounters["crossWindowRebindRecoveredPasses", default: 0] + let pendingDetachedVisibleCount = uiTestTerminalGeometryPendingDetachedPanels.count + let pendingCrossWindowHostedCount = uiTestTerminalGeometryPendingCrossWindowHostedViews.count + + return [ + "terminalGeometryTraceEnabled": "1", + "terminalGeometryTraceEventCount": String(uiTestTerminalGeometryTraceCounters["eventCount", default: 0]), + "terminalGeometryTraceDetachedVisiblePasses": String(detachedVisiblePasses), + "terminalGeometryTraceTransientPortalRemountPasses": String(transientPortalRemountPasses), + "terminalGeometryTraceReattachRequests": String(reattachRequests), + "terminalGeometryTraceReattachRecoveredPasses": String(reattachRecoveredPasses), + "terminalGeometryTraceFullSyncTransientPasses": String(fullSyncTransientPasses), + "terminalGeometryTraceFullSyncSettledPasses": String(fullSyncSettledPasses), + "terminalGeometryTraceCrossWindowRebindStarts": String(crossWindowRebindStarts), + "terminalGeometryTraceCrossWindowRebindRecoveredPasses": String(crossWindowRebindRecoveredPasses), + "terminalGeometryTracePendingDetachedVisibleCount": String(pendingDetachedVisibleCount), + "terminalGeometryTracePendingCrossWindowHostedCount": String(pendingCrossWindowHostedCount), + "terminalGeometryTraceSawTransientDetachedVisibleState": (detachedVisiblePasses > 0 || transientPortalRemountPasses > 0) ? "1" : "0", + "terminalGeometryTraceRecoveredAfterTransientDetach": reattachRecoveredPasses > 0 ? "1" : "0", + "terminalGeometryTraceSawCrossWindowTransientDetach": crossWindowRebindStarts > 0 ? "1" : "0", + "terminalGeometryTraceCrossWindowRecovered": crossWindowRebindRecoveredPasses > 0 ? "1" : "0", + "terminalGeometryTraceSawPotentialBlankStall": (pendingDetachedVisibleCount > 0 || pendingCrossWindowHostedCount > 0) ? "1" : "0", + "terminalGeometryTraceRecentEvents": uiTestTerminalGeometryTraceEvents.joined(separator: " || "), + ] + } + + private func persistUITestTerminalGeometryTraceIfNeeded() { + let env = ProcessInfo.processInfo.environment + guard isUITestTerminalGeometryTraceEnabled(env), + let path = env["CMUX_UI_TEST_DIAGNOSTICS_PATH"], + !path.isEmpty else { return } + + var payload = loadUITestDiagnostics(at: path) + for (key, value) in currentUITestTerminalGeometryTracePayload() { + payload[key] = value + } + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + } + private func moveUITestWindowToTargetDisplayIfNeeded(attempt: Int = 0) { let env = ProcessInfo.processInfo.environment guard let rawDisplayID = env["CMUX_UI_TEST_TARGET_DISPLAY_ID"], @@ -2677,7 +2782,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() - setupBonsplitTabDragUITestIfNeeded() setupMultiWindowNotificationsUITestIfNeeded() setupDisplayResolutionUITestDiagnosticsIfNeeded() @@ -4452,6 +4556,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent commandPaletteEscapeSuppressionByWindowId.contains(windowId) else { return false } + if event.isARepeat { + return true + } let startedAt = commandPaletteEscapeSuppressionStartedAtByWindowId[windowId] ?? 0 if ProcessInfo.processInfo.systemUptime - startedAt <= 0.35 { return true @@ -5487,6 +5594,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) #endif guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + let orphanedContexts = Array( + Set( + mainWindowContexts.values.compactMap { context -> ObjectIdentifier? in + resolvedWindow(for: context) == nil ? ObjectIdentifier(context) : nil + } + ) + ).compactMap { orphanedId in + mainWindowContexts.values.first { ObjectIdentifier($0) == orphanedId } + } + for orphanedContext in orphanedContexts { + discardOrphanedMainWindowContext(orphanedContext) + } #if DEBUG logWorkspaceCreationRouting( phase: "no_context", @@ -5804,7 +5923,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } else { window.center() } - window.contentView = MainWindowHostingView(rootView: root) + window.contentView = NSHostingView(rootView: root) // Apply shared window styling. attachUpdateAccessory(to: window) @@ -6997,176 +7116,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func setupBonsplitTabDragUITestIfNeeded() { - guard !didSetupBonsplitTabDragUITest else { return } - didSetupBonsplitTabDragUITest = true - let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] == "1" else { return } - guard tabManager != nil else { return } - let startWithHiddenSidebar = env["CMUX_UI_TEST_BONSPLIT_START_WITH_HIDDEN_SIDEBAR"] == "1" - - let deadline = Date().addingTimeInterval(20.0) - func hasMainTerminalWindow() -> Bool { - NSApp.windows.contains { window in - guard let raw = window.identifier?.rawValue else { return false } - return raw == "cmux.main" || raw.hasPrefix("cmux.main.") - } - } - - func runSetupWhenWindowReady() { - guard Date() < deadline else { - writeBonsplitTabDragUITestData(["setupError": "Timed out waiting for main window"]) - return - } - guard hasMainTerminalWindow() else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - runSetupWhenWindowReady() - } - return - } - if let mainWindow = NSApp.windows.first(where: { window in - guard let raw = window.identifier?.rawValue else { return false } - return raw == "cmux.main" || raw.hasPrefix("cmux.main.") - }) { - let screenFrame = mainWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame - if let screenFrame { - let targetSize = NSSize(width: min(960, screenFrame.width - 80), height: min(720, screenFrame.height - 80)) - let targetOrigin = NSPoint( - x: screenFrame.minX + 40, - y: screenFrame.maxY - 40 - targetSize.height - ) - let targetFrame = NSRect(origin: targetOrigin, size: targetSize) - if !mainWindow.frame.equalTo(targetFrame) { - mainWindow.setFrame(targetFrame, display: true) - } - } - } - guard let tabManager = self.tabManager, - let workspace = tabManager.selectedWorkspace ?? tabManager.tabs.first, - let alphaPanelId = workspace.focusedPanelId else { - self.writeBonsplitTabDragUITestData(["setupError": "Missing initial workspace or panel"]) - return - } - - let workspaceTitle = "UITest Workspace" - let alphaTitle = "UITest Alpha" - let betaTitle = "UITest Beta" - tabManager.setCustomTitle(tabId: workspace.id, title: workspaceTitle) - workspace.setPanelCustomTitle(panelId: alphaPanelId, title: alphaTitle) - tabManager.newSurface() - - guard let betaPanelId = workspace.focusedPanelId, betaPanelId != alphaPanelId else { - self.writeBonsplitTabDragUITestData(["setupError": "Failed to create second surface"]) - return - } - - workspace.setPanelCustomTitle(panelId: betaPanelId, title: betaTitle) - if startWithHiddenSidebar { - self.sidebarState?.isVisible = false - } - self.writeBonsplitTabDragUITestData([ - "ready": "1", - "sidebarVisible": startWithHiddenSidebar ? "0" : "1", - "workspaceId": workspace.id.uuidString, - "workspaceTitle": workspaceTitle, - "alphaTitle": alphaTitle, - "betaTitle": betaTitle, - "alphaPanelId": alphaPanelId.uuidString, - "betaPanelId": betaPanelId.uuidString, - ]) - self.startBonsplitTabDragUITestRecorder( - workspaceId: workspace.id, - alphaPanelId: alphaPanelId, - betaPanelId: betaPanelId - ) - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - guard self != nil else { return } - runSetupWhenWindowReady() - } - } - - private func bonsplitTabDragUITestDataPath() -> String? { - let env = ProcessInfo.processInfo.environment - guard env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP"] == "1", - let path = env["CMUX_UI_TEST_BONSPLIT_TAB_DRAG_PATH"], - !path.isEmpty else { - return nil - } - return path - } - - private func startBonsplitTabDragUITestRecorder( - workspaceId: UUID, - alphaPanelId: UUID, - betaPanelId: UUID - ) { - bonsplitTabDragUITestRecorder?.cancel() - bonsplitTabDragUITestRecorder = nil - - let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule(deadline: .now(), repeating: .milliseconds(100)) - timer.setEventHandler { [weak self] in - self?.recordBonsplitTabDragUITestState( - workspaceId: workspaceId, - alphaPanelId: alphaPanelId, - betaPanelId: betaPanelId - ) - } - bonsplitTabDragUITestRecorder = timer - timer.resume() - } - - private func recordBonsplitTabDragUITestState( - workspaceId: UUID, - alphaPanelId: UUID, - betaPanelId: UUID - ) { - guard let tabManager else { return } - guard let workspace = (tabManager.tabs.first { $0.id == workspaceId } ?? tabManager.selectedWorkspace ?? tabManager.tabs.first) else { - return - } - - let trackedPaneId = workspace.paneId(forPanelId: alphaPanelId) - ?? workspace.paneId(forPanelId: betaPanelId) - ?? workspace.bonsplitController.focusedPaneId - ?? workspace.bonsplitController.allPaneIds.first - guard let trackedPaneId else { return } - - let titles: [String] = workspace.bonsplitController.tabs(inPane: trackedPaneId).compactMap { tab in - guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { return nil } - return workspace.panelTitle(panelId: panelId) - } - let selectedTitle = workspace.bonsplitController.selectedTab(inPane: trackedPaneId) - .flatMap { workspace.panelIdFromSurfaceId($0.id) } - .flatMap { workspace.panelTitle(panelId: $0) } ?? "" - - writeBonsplitTabDragUITestData([ - "trackedPaneId": trackedPaneId.description, - "trackedPaneTabTitles": titles.joined(separator: "|"), - "trackedPaneTabCount": String(titles.count), - "trackedPaneSelectedTitle": selectedTitle, - ]) - } - - private func writeBonsplitTabDragUITestData(_ updates: [String: String]) { - guard let path = bonsplitTabDragUITestDataPath() else { return } - var payload = loadBonsplitTabDragUITestData(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 loadBonsplitTabDragUITestData(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 isGotoSplitUITestRecordingEnabled() -> Bool { let env = ProcessInfo.processInfo.environment return env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" || env["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] == "1" @@ -8673,7 +8622,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func installShortcutDefaultsObserver() { guard shortcutDefaultsObserver == nil else { return } shortcutDefaultsObserver = NotificationCenter.default.addObserver( - forName: KeyboardShortcutSettings.didChangeNotification, + forName: UserDefaults.didChangeNotification, object: nil, queue: .main ) { [weak self] _ in @@ -8886,11 +8835,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func handleCustomShortcut(event: NSEvent) -> Bool { // `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys. // Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed. - // When a non-Latin input source is active (Korean, Chinese, Japanese, etc.), - // charactersIgnoringModifiers returns non-ASCII characters that never match - // Latin shortcut keys. Normalize via KeyboardLayout so downstream comparisons - // (Cmd+1-9, Ctrl+1-9, omnibar N/P, command palette, etc.) work correctly. - let chars = KeyboardLayout.normalizedCharacters(for: event) + let chars = (event.charactersIgnoringModifiers ?? "").lowercased() let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) let hasControl = flags.contains(.control) let hasCommand = flags.contains(.command) @@ -9199,13 +9144,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + let eventTargetWindow = event.window + ?? (event.windowNumber > 0 ? NSApp.window(withWindowNumber: event.windowNumber) : nil) let hasEventWindowContext = shortcutEventHasAddressableWindow(event) let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) if hasEventWindowContext && !didSynchronizeShortcutContext { + // AppKit allows auxiliary windows to become key without becoming the app's + // main terminal window. Those windows still need to receive their own close + // shortcut handling instead of being rejected as an unresolved main-window route. + if let eventTargetWindow, !isMainTerminalWindow(eventTargetWindow) { + // Continue into the shortcut-specific auxiliary-window paths below. + } else { #if DEBUG - dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") + dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") #endif - return false + return false + } } // Keep keyboard routing deterministic after split close/reparent transitions: @@ -9414,10 +9368,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent event: event, shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) ) { + let shortcutTargetWindow = eventTargetWindow ?? NSApp.keyWindow ?? NSApp.mainWindow // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. // This AppDelegate path is a fallback for cases where AppKit routes the // event through the global shortcut handler first. - if let targetWindow = [NSApp.keyWindow, event.window] + if let targetWindow = [NSApp.keyWindow, eventTargetWindow] .compactMap({ $0 }) .first(where: { $0.identifier?.rawValue == "cmux.browser-popup" }) { #if DEBUG @@ -9425,12 +9380,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif targetWindow.performClose(nil) return true - } else if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + } else if let targetWindow = shortcutTargetWindow, cmuxWindowShouldOwnCloseShortcut(targetWindow) { targetWindow.performClose(nil) } else { - let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { + if let terminalContext = focusedTerminalShortcutContext(preferredWindow: shortcutTargetWindow) { #if DEBUG dlog( "shortcut.cmdW route=ghostty workspace=\(terminalContext.workspaceId.uuidString.prefix(5)) " + @@ -9475,33 +9429,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // Numeric shortcuts for specific workspaces (9 = last workspace) - if let manager = tabManager, - let digit = numberedShortcutDigit( - event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) - ), - let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { + // Numeric shortcuts for specific sidebar tabs: Cmd+1-9 (9 = last workspace) + if flags == [.command], + let manager = tabManager, + let num = Int(chars), + let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: num, workspaceCount: manager.tabs.count) { #if DEBUG dlog( - "shortcut.action name=workspaceDigit digit=\(digit) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + "shortcut.action name=workspaceDigit digit=\(num) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" ) #endif manager.selectTab(at: targetIndex) return true } - // Numeric shortcuts for surfaces within the focused pane (9 = last) - if let digit = numberedShortcutDigit( - event: event, - shortcut: KeyboardShortcutSettings.shortcut(for: .selectSurfaceByNumber) - ) { - if digit == 9 { - tabManager?.selectLastSurface() - } else { - tabManager?.selectSurface(at: digit - 1) + // Numeric shortcuts for surfaces within pane: Ctrl+1-9 (9 = last) + if flags == [.control] { + if let num = Int(chars), num >= 1 && num <= 9 { + if num == 9 { + tabManager?.selectLastSurface() + } else { + tabManager?.selectSurface(at: num - 1) + } + return true } - return true } // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). @@ -10483,13 +10434,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // For command-based shortcuts, trust AppKit's layout-aware characters when present. // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. - // When a non-Latin input source is active (Korean, Chinese, Japanese, etc.), - // charactersIgnoringModifiers returns non-ASCII characters that can never match - // a Latin shortcut key — skip this guard and fall through to layout-based matching. let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) - let eventCharsAreASCII = eventCharsIgnoringModifiers?.allSatisfy(\.isASCII) ?? true if hasEventChars, - eventCharsAreASCII, flags.contains(.command), !flags.contains(.control), shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) { @@ -10512,15 +10458,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for // command punctuation shortcuts, since some non-US layouts report different characters // for the same physical key even when menu-equivalent semantics should still apply. - // When a non-Latin input source is active, treat non-ASCII event chars the same as - // absent chars — they carry no usable Latin key identity. - let hasUsableEventChars = hasEventChars && eventCharsAreASCII let allowANSIKeyCodeFallback = flags.contains(.control) || (flags.contains(.command) && !flags.contains(.control) && ( !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) - || (!hasUsableEventChars && (layoutCharacter?.isEmpty ?? true)) + || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) )) if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { return event.keyCode == expectedKeyCode @@ -10528,46 +10471,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { - let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function, .capsLock]) - guard flags == shortcut.modifierFlags else { return nil } - - if let digit = numberedShortcutDigit( - eventCharacter: event.charactersIgnoringModifiers, - applyShiftSymbolNormalization: flags.contains(.shift), - eventKeyCode: event.keyCode - ) { - return digit - } - - let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) - if let digit = numberedShortcutDigit( - eventCharacter: layoutCharacter, - applyShiftSymbolNormalization: false, - eventKeyCode: event.keyCode - ) { - return digit - } - - return digitForNumberKeyCode(event.keyCode) - } - - private func numberedShortcutDigit( - eventCharacter: String?, - applyShiftSymbolNormalization: Bool, - eventKeyCode: UInt16 - ) -> Int? { - guard let eventCharacter, !eventCharacter.isEmpty else { return nil } - let normalized = normalizedShortcutEventCharacter( - eventCharacter, - applyShiftSymbolNormalization: applyShiftSymbolNormalization, - eventKeyCode: eventKeyCode - ) - guard let digit = Int(normalized), (1...9).contains(digit) else { return nil } - return digit - } - private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool { guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else { return false @@ -10687,22 +10590,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { - switch keyCode { - case 18: return 1 // kVK_ANSI_1 - case 19: return 2 // kVK_ANSI_2 - case 20: return 3 // kVK_ANSI_3 - case 21: return 4 // kVK_ANSI_4 - case 23: return 5 // kVK_ANSI_5 - case 22: return 6 // kVK_ANSI_6 - case 26: return 7 // kVK_ANSI_7 - case 28: return 8 // kVK_ANSI_8 - case 25: return 9 // kVK_ANSI_9 - default: - return nil - } - } - /// Match arrow key shortcuts using keyCode /// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing. private func matchArrowShortcut(event: NSEvent, shortcut: StoredShortcut, keyCode: UInt16) -> Bool { @@ -11083,7 +10970,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent contextContainingTabId(tabId)?.tabManager.tabs.first(where: { $0.id == tabId }) } - /// Returns the `Workspace` that owns `tabId`, if any. @MainActor func workspaceFor(tabId: UUID) -> Workspace? { workspaceForMainActor(tabId: tabId) @@ -12533,13 +12419,6 @@ private extension NSWindow { in window: NSWindow, event: NSEvent? ) -> CmuxWebView? { - // Browser find runs in the portal slot alongside the hosted WKWebView. - // Treat its native field editor chain as browser chrome, not as web content, - // so Cmd+F can move first responder into the find field while web focus is suppressed. - if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) != nil { - return nil - } - if let webView = cmuxOwningWebView(for: responder) { return webView } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 4c4588a8a23d..53178a733d59 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -7,6 +7,10 @@ import Bonsplit private var cmuxWindowTerminalPortalKey: UInt8 = 0 private var cmuxWindowTerminalPortalCloseObserverKey: UInt8 = 0 +private func portalRectSignature(_ rect: NSRect) -> String { + String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) +} + #if DEBUG private func portalDebugToken(_ view: NSView?) -> String { guard let view else { return "nil" } @@ -15,7 +19,7 @@ private func portalDebugToken(_ view: NSView?) -> String { } private func portalDebugFrame(_ rect: NSRect) -> String { - String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) + portalRectSignature(rect) } private func portalDebugFrameInWindow(_ view: NSView?) -> String { @@ -588,22 +592,76 @@ final class WindowTerminalPortal: NSObject { private var installConstraints: [NSLayoutConstraint] = [] private var hasDeferredFullSyncScheduled = false private var hasExternalGeometrySyncScheduled = false + private var deferredFullSyncQueuedCoverage: FullSyncCoverage? + private var deferredFullSyncQueuedForce = false + private var externalGeometryQueuedCoverage: FullSyncCoverage? + private var lastSatisfiedFullSyncCoverage: FullSyncCoverage? + private var syncCoalescingEpoch: UInt64 = 0 private var geometryObservers: [NSObjectProtocol] = [] #if DEBUG private var lastLoggedBonsplitContainerSignature: String? #endif + private struct FullSyncCoverage { + let epoch: UInt64 + let hostFrame: NSRect + let hostBounds: NSRect + let installTargetSignature: String + let anchorLayoutSignature: String + } + + private struct SyncOutcome { + let anchorId: ObjectIdentifier + let rawFrame: NSRect + let targetFrame: NSRect + let hostBounds: NSRect + let shouldHide: Bool + let visibleInUI: Bool + let hostedHidden: Bool + } + + private enum HostedSyncResult { + case settled + case transient + case skipped + } + + private enum FullSyncPassResult: String { + case settled + case transient + case vacuous + } + private struct Entry { weak var hostedView: GhosttySurfaceScrollView? weak var anchorView: NSView? var visibleInUI: Bool var zPriority: Int var transientRecoveryRetriesRemaining: Int + var lastSynchronizedOutcome: SyncOutcome? } private var entriesByHostedId: [ObjectIdentifier: Entry] = [:] private var hostedByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:] + private static func syncOutcomeApproximatelyEqual(_ lhs: SyncOutcome, _ rhs: SyncOutcome) -> Bool { + lhs.anchorId == rhs.anchorId && + rectApproximatelyEqual(lhs.rawFrame, rhs.rawFrame) && + rectApproximatelyEqual(lhs.targetFrame, rhs.targetFrame) && + rectApproximatelyEqual(lhs.hostBounds, rhs.hostBounds) && + lhs.shouldHide == rhs.shouldHide && + lhs.visibleInUI == rhs.visibleInUI && + lhs.hostedHidden == rhs.hostedHidden + } + + private static func fullSyncCoverageSatisfies(_ lhs: FullSyncCoverage, _ rhs: FullSyncCoverage) -> Bool { + lhs.epoch >= rhs.epoch && + rectApproximatelyEqual(lhs.hostFrame, rhs.hostFrame) && + rectApproximatelyEqual(lhs.hostBounds, rhs.hostBounds) && + lhs.installTargetSignature == rhs.installTargetSignature && + lhs.anchorLayoutSignature == rhs.anchorLayoutSignature + } + init(window: NSWindow) { self.window = window super.init() @@ -680,16 +738,155 @@ final class WindowTerminalPortal: NSObject { geometryObservers.removeAll() } + private func markPortalStateMutated() { + syncCoalescingEpoch &+= 1 + } + + private func currentAnchorLayoutSignature() -> String { + let components = entriesByHostedId + .sorted { lhs, rhs in + String(describing: lhs.key) < String(describing: rhs.key) + } + .map { hostedId, entry -> String in + guard let anchorView = entry.anchorView else { + return "\(hostedId):missing" + } + let anchorId = ObjectIdentifier(anchorView) + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let visible = entry.visibleInUI ? 1 : 0 + return "\(hostedId):\(anchorId):\(portalRectSignature(frameInWindow)):\(visible)" + } + return components.joined(separator: "|") + } + + private func currentInstallTargetSignature() -> String { + guard let window else { return "missing-window" } + guard let (container, reference) = + installedTargetIfStillValid(for: window) ?? installationTarget(for: window) + else { + return "missing-target" + } + return "\(ObjectIdentifier(container)):\(ObjectIdentifier(reference))" + } + + private func currentFullSyncCoverage() -> FullSyncCoverage { + FullSyncCoverage( + epoch: syncCoalescingEpoch, + hostFrame: hostView.frame, + hostBounds: hostView.bounds, + installTargetSignature: currentInstallTargetSignature(), + anchorLayoutSignature: currentAnchorLayoutSignature() + ) + } + + private func hasSatisfiedFullSync(for coverage: FullSyncCoverage) -> Bool { + guard let lastSatisfiedFullSyncCoverage else { return false } + return Self.fullSyncCoverageSatisfies(lastSatisfiedFullSyncCoverage, coverage) + } + + private func hasQueuedDeferredFullSync(for coverage: FullSyncCoverage) -> Bool { + guard let deferredFullSyncQueuedCoverage else { return false } + return Self.fullSyncCoverageSatisfies(deferredFullSyncQueuedCoverage, coverage) + } + + private func hasQueuedExternalGeometrySync(for coverage: FullSyncCoverage) -> Bool { + guard let externalGeometryQueuedCoverage else { return false } + return Self.fullSyncCoverageSatisfies(externalGeometryQueuedCoverage, coverage) + } + + private func markFullSyncSatisfied() { + lastSatisfiedFullSyncCoverage = currentFullSyncCoverage() + } + fileprivate func scheduleExternalGeometrySynchronize() { - guard !hasExternalGeometrySyncScheduled else { return } + let coverage = currentFullSyncCoverage() + if hasSatisfiedFullSync(for: coverage) { +#if DEBUG + dlog( + "portal.geometry.schedule.skip host=\(portalDebugToken(hostView)) " + + "window=\(window?.windowNumber ?? -1) reason=alreadySatisfied" + ) +#endif + return + } + if hasQueuedExternalGeometrySync(for: coverage) { +#if DEBUG + dlog( + "portal.geometry.schedule.skip host=\(portalDebugToken(hostView)) " + + "window=\(window?.windowNumber ?? -1) reason=alreadyQueued" + ) +#endif + return + } + if hasExternalGeometrySyncScheduled { + externalGeometryQueuedCoverage = coverage +#if DEBUG + dlog( + "portal.geometry.schedule.skip host=\(portalDebugToken(hostView)) " + + "window=\(window?.windowNumber ?? -1) reason=queueUpdated" + ) +#endif + return + } hasExternalGeometrySyncScheduled = true + externalGeometryQueuedCoverage = coverage let isDragEvent = TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true || isDragEvent) +#if DEBUG + dlog( + "portal.geometry.schedule host=\(portalDebugToken(hostView)) " + + "window=\(window?.windowNumber ?? -1) drag=\(isDragEvent ? 1 : 0) " + + "hostLiveResize=\(hostView.inLiveResize ? 1 : 0) " + + "windowLiveResize=\(window?.inLiveResize == true ? 1 : 0) " + + "settled=\(requiresSettledLayout ? 1 : 0)" + ) +#endif DispatchQueue.main.async { [weak self] in guard let self else { return } let performSync = { + let requestedCoverage = self.externalGeometryQueuedCoverage ?? coverage self.hasExternalGeometrySyncScheduled = false - self.synchronizeAllEntriesFromExternalGeometryChange() + self.externalGeometryQueuedCoverage = nil + if self.hasSatisfiedFullSync(for: requestedCoverage) { +#if DEBUG + dlog( + "portal.geometry.sync.skip host=\(portalDebugToken(self.hostView)) " + + "window=\(self.window?.windowNumber ?? -1) reason=alreadySatisfied" + ) +#endif + return + } +#if DEBUG + dlog( + "portal.geometry.sync host=\(portalDebugToken(self.hostView)) " + + "window=\(self.window?.windowNumber ?? -1)" + ) +#endif + let syncResult = self.synchronizeAllEntriesFromExternalGeometryChange() +#if DEBUG + if syncResult == .transient { + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "fullSyncTransient", + fields: [ + "hostedId": portalDebugToken(self.hostView), + "source": "externalGeometry", + "windowNumber": String(self.window?.windowNumber ?? -1), + ] + ) + } else if syncResult == .settled { + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "fullSyncSettled", + fields: [ + "hostedId": portalDebugToken(self.hostView), + "source": "externalGeometry", + "windowNumber": String(self.window?.windowNumber ?? -1), + ] + ) + } +#endif + if syncResult == .settled { + self.markFullSyncSatisfied() + } } if requiresSettledLayout { DispatchQueue.main.async(execute: performSync) @@ -736,10 +933,11 @@ final class WindowTerminalPortal: NSObject { return frameInContainer.width > 1 && frameInContainer.height > 1 } - fileprivate func synchronizeAllEntriesFromExternalGeometryChange() { - guard ensureInstalled() else { return } + private func synchronizeAllEntriesFromExternalGeometryChange() -> FullSyncPassResult { + guard ensureInstalled() else { return .vacuous } synchronizeLayoutHierarchy() - synchronizeAllHostedViews(excluding: nil) + let syncResult = synchronizeAllHostedViews(excluding: nil) + guard syncResult == .settled else { return syncResult } // During live resize, AppKit can deliver frame churn where host/container geometry // settles a tick before the terminal's own scroll/surface hierarchy. Only force an @@ -750,6 +948,7 @@ final class WindowTerminalPortal: NSObject { hostedView.refreshSurfaceNow(reason: "portal.externalGeometrySync") } } + return .settled } private func ensureDividerOverlayOnTop() { @@ -978,6 +1177,7 @@ final class WindowTerminalPortal: NSObject { func detachHostedView(withId hostedId: ObjectIdentifier) { guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } + markPortalStateMutated() if let anchor = entry.anchorView { hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor)) } @@ -999,8 +1199,10 @@ final class WindowTerminalPortal: NSObject { func hideEntry(forHostedId hostedId: ObjectIdentifier) { guard var entry = entriesByHostedId[hostedId] else { return } guard entry.visibleInUI else { return } + markPortalStateMutated() entry.visibleInUI = false entry.transientRecoveryRetriesRemaining = 0 + entry.lastSynchronizedOutcome = nil entriesByHostedId[hostedId] = entry entry.hostedView?.isHidden = true #if DEBUG @@ -1013,10 +1215,13 @@ final class WindowTerminalPortal: NSObject { /// won't hide a view that updateNSView has already marked as visible. func updateEntryVisibility(forHostedId hostedId: ObjectIdentifier, visibleInUI: Bool) { guard var entry = entriesByHostedId[hostedId] else { return } + guard entry.visibleInUI != visibleInUI else { return } + markPortalStateMutated() entry.visibleInUI = visibleInUI if !visibleInUI { entry.transientRecoveryRetriesRemaining = 0 } + entry.lastSynchronizedOutcome = nil entriesByHostedId[hostedId] = entry } @@ -1046,6 +1251,35 @@ final class WindowTerminalPortal: NSObject { detachHostedView(withId: previousHostedId) } + let didChangeAnchor: Bool = { + guard let previousAnchor = previousEntry?.anchorView else { return true } + return previousAnchor !== anchorView + }() + let visibilityChanged = previousEntry?.visibleInUI != visibleInUI + let priorityChanged = previousEntry?.zPriority != zPriority + let requiresAttach = hostedView.superview !== hostView + let isIdempotentRebind = + previousEntry != nil && + !didChangeAnchor && + !visibilityChanged && + !priorityChanged && + !requiresAttach + + if isIdempotentRebind { +#if DEBUG + dlog( + "portal.bind.skip hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) reason=idempotent" + ) +#endif + ensureDividerOverlayOnTop() + _ = synchronizeHostedView(withId: hostedId) + pruneDeadEntries() + return + } + + markPortalStateMutated() + if let oldEntry = entriesByHostedId[hostedId], let oldAnchor = oldEntry.anchorView, oldAnchor !== anchorView { @@ -1058,17 +1292,13 @@ final class WindowTerminalPortal: NSObject { anchorView: anchorView, visibleInUI: visibleInUI, zPriority: zPriority, - transientRecoveryRetriesRemaining: 0 + transientRecoveryRetriesRemaining: 0, + lastSynchronizedOutcome: nil ) - - let didChangeAnchor: Bool = { - guard let previousAnchor = previousEntry?.anchorView else { return true } - return previousAnchor !== anchorView - }() let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min) #if DEBUG - if previousEntry == nil || didChangeAnchor || becameVisible || priorityIncreased || hostedView.superview !== hostView { + if previousEntry == nil || didChangeAnchor || becameVisible || priorityIncreased || requiresAttach { dlog( "portal.bind hosted=\(portalDebugToken(hostedView)) " + "anchor=\(portalDebugToken(anchorView)) prevAnchor=\(portalDebugToken(previousEntry?.anchorView)) " + @@ -1104,7 +1334,7 @@ final class WindowTerminalPortal: NSObject { // before the hosted view enters a window. hostedView.reconcileGeometryNow() - if hostedView.superview !== hostView { + if requiresAttach { #if DEBUG dlog( "portal.reparent hosted=\(portalDebugToken(hostedView)) " + @@ -1128,7 +1358,7 @@ final class WindowTerminalPortal: NSObject { ensureDividerOverlayOnTop() - synchronizeHostedView(withId: hostedId) + _ = synchronizeHostedView(withId: hostedId) scheduleDeferredFullSynchronizeAll() pruneDeadEntries() } @@ -1140,35 +1370,148 @@ final class WindowTerminalPortal: NSObject { let anchorId = ObjectIdentifier(anchorView) let primaryHostedId = hostedByAnchorId[anchorId] if let primaryHostedId { - synchronizeHostedView(withId: primaryHostedId) + _ = synchronizeHostedView(withId: primaryHostedId) } // Failsafe: during aggressive divider drags/structural churn, one anchor can miss a // geometry callback while another fires. Reconcile all mapped hosted views so no stale // frame remains "stuck" onscreen until the next interaction. - synchronizeAllHostedViews(excluding: primaryHostedId) + let syncResult = synchronizeAllHostedViews(excluding: primaryHostedId) +#if DEBUG + if syncResult == .transient { + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "fullSyncTransient", + fields: [ + "hostedId": portalDebugToken(hostView), + "source": "anchorSync", + "windowNumber": String(window?.windowNumber ?? -1), + ] + ) + } else if syncResult == .settled { + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "fullSyncSettled", + fields: [ + "hostedId": portalDebugToken(hostView), + "source": "anchorSync", + "windowNumber": String(window?.windowNumber ?? -1), + ] + ) + } +#endif + if syncResult == .settled { + markFullSyncSatisfied() + } scheduleDeferredFullSynchronizeAll() } - private func scheduleDeferredFullSynchronizeAll() { - guard !hasDeferredFullSyncScheduled else { return } + private func scheduleDeferredFullSynchronizeAll(force: Bool = false) { + let coverage = currentFullSyncCoverage() + if !force, hasSatisfiedFullSync(for: coverage) { +#if DEBUG + dlog( + "portal.deferFull.skip host=\(portalDebugToken(hostView)) " + + "window=\(window?.windowNumber ?? -1) reason=alreadySatisfied" + ) +#endif + return + } + if !force, hasQueuedDeferredFullSync(for: coverage) { +#if DEBUG + dlog( + "portal.deferFull.skip host=\(portalDebugToken(hostView)) " + + "window=\(window?.windowNumber ?? -1) reason=alreadyQueued" + ) +#endif + return + } + if hasDeferredFullSyncScheduled { + deferredFullSyncQueuedCoverage = coverage + deferredFullSyncQueuedForce = deferredFullSyncQueuedForce || force +#if DEBUG + dlog( + "portal.deferFull.skip host=\(portalDebugToken(hostView)) " + + "window=\(window?.windowNumber ?? -1) reason=queueUpdated" + ) +#endif + return + } hasDeferredFullSyncScheduled = true + deferredFullSyncQueuedCoverage = coverage + deferredFullSyncQueuedForce = force DispatchQueue.main.async { [weak self] in guard let self else { return } + let requestedCoverage = self.deferredFullSyncQueuedCoverage ?? coverage + let requestedForce = self.deferredFullSyncQueuedForce self.hasDeferredFullSyncScheduled = false - self.synchronizeAllHostedViews(excluding: nil) + self.deferredFullSyncQueuedCoverage = nil + self.deferredFullSyncQueuedForce = false + if !requestedForce, self.hasSatisfiedFullSync(for: requestedCoverage) { +#if DEBUG + dlog( + "portal.deferFull.sync.skip host=\(portalDebugToken(self.hostView)) " + + "window=\(self.window?.windowNumber ?? -1) reason=alreadySatisfied" + ) +#endif + return + } + let syncResult = self.synchronizeAllHostedViews(excluding: nil) +#if DEBUG + dlog( + "portal.deferFull.sync.result host=\(portalDebugToken(self.hostView)) " + + "window=\(self.window?.windowNumber ?? -1) result=\(syncResult.rawValue)" + ) +#endif +#if DEBUG + if syncResult == .transient { + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "fullSyncTransient", + fields: [ + "hostedId": portalDebugToken(self.hostView), + "source": "deferredFull", + "windowNumber": String(self.window?.windowNumber ?? -1), + ] + ) + } else if syncResult == .settled { + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "fullSyncSettled", + fields: [ + "hostedId": portalDebugToken(self.hostView), + "source": "deferredFull", + "windowNumber": String(self.window?.windowNumber ?? -1), + ] + ) + } +#endif + if syncResult == .settled { + self.markFullSyncSatisfied() + } } } - private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) { - guard ensureInstalled() else { return } + private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) -> FullSyncPassResult { + guard ensureInstalled() else { return .vacuous } synchronizeLayoutHierarchy() pruneDeadEntries() let hostedIds = Array(entriesByHostedId.keys) + var didConsiderHostedView = false + var sawTransient = false for hostedId in hostedIds { if hostedId == hostedIdToSkip { continue } - synchronizeHostedView(withId: hostedId) + switch synchronizeHostedView(withId: hostedId) { + case .settled: + didConsiderHostedView = true + break + case .transient: + didConsiderHostedView = true + sawTransient = true + case .skipped: + break + } } + if !didConsiderHostedView { + return .vacuous + } + return sawTransient ? .transient : .settled } private func resetTransientRecoveryRetryIfNeeded(forHostedId hostedId: ObjectIdentifier, entry: inout Entry) { @@ -1198,17 +1541,17 @@ final class WindowTerminalPortal: NSObject { ) #endif if entry.transientRecoveryRetriesRemaining > 0 { - scheduleDeferredFullSynchronizeAll() + scheduleDeferredFullSynchronizeAll(force: true) } return true } - private func synchronizeHostedView(withId hostedId: ObjectIdentifier) { - guard ensureInstalled() else { return } - guard var entry = entriesByHostedId[hostedId] else { return } + private func synchronizeHostedView(withId hostedId: ObjectIdentifier) -> HostedSyncResult { + guard ensureInstalled() else { return .skipped } + guard var entry = entriesByHostedId[hostedId] else { return .skipped } guard let hostedView = entry.hostedView else { entriesByHostedId.removeValue(forKey: hostedId) - return + return .skipped } guard let anchorView = entry.anchorView, let window else { // Only hide if the entry is not marked visibleInUI. When a workspace is @@ -1230,7 +1573,7 @@ final class WindowTerminalPortal: NSObject { reason: "missingAnchorOrWindow" ) } - return + return entry.visibleInUI ? .transient : .settled } guard anchorView.window === window else { #if DEBUG @@ -1256,7 +1599,7 @@ final class WindowTerminalPortal: NSObject { "reason=anchorWindowMismatch frame=\(portalDebugFrame(hostedView.frame))" ) #endif - return + return .transient } } else { resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) @@ -1270,7 +1613,7 @@ final class WindowTerminalPortal: NSObject { reason: "anchorWindowMismatch" ) } - return + return entry.visibleInUI ? .transient : .settled } _ = synchronizeHostFrameToReference() @@ -1310,7 +1653,7 @@ final class WindowTerminalPortal: NSObject { "reason=hostBoundsNotReady frame=\(portalDebugFrame(hostedView.frame))" ) #endif - return + return .transient } } else { resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) @@ -1328,7 +1671,7 @@ final class WindowTerminalPortal: NSObject { scheduleDeferredFullSynchronizeAll() } } - return + return entry.visibleInUI ? .transient : .settled } let hasFiniteFrame = frameInHost.origin.x.isFinite && @@ -1382,6 +1725,24 @@ final class WindowTerminalPortal: NSObject { !hostedView.isHidden let oldFrame = hostedView.frame + let oldBounds = hostedView.bounds + let expectedBounds = NSRect(origin: .zero, size: targetFrame.size) + let frameNeedsUpdate = + hasFiniteFrame && + (!Self.rectApproximatelyEqual(oldFrame, targetFrame) || + !Self.rectApproximatelyEqual(oldBounds, expectedBounds)) + let willHide = shouldHide && !hostedView.isHidden && !shouldPreserveVisibleOnTransientGeometry + let willReveal = !shouldHide && hostedView.isHidden && revealReadyForDisplay + let finalHiddenState = willHide ? true : (willReveal ? false : hostedView.isHidden) + let nextOutcome = SyncOutcome( + anchorId: ObjectIdentifier(anchorView), + rawFrame: frameInHost, + targetFrame: targetFrame, + hostBounds: hostBounds, + shouldHide: shouldHide, + visibleInUI: entry.visibleInUI, + hostedHidden: finalHiddenState + ) #if DEBUG let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) if frameWasClamped { @@ -1407,6 +1768,29 @@ final class WindowTerminalPortal: NSObject { } #endif + if transientRecoveryReason == nil, + !shouldDeferReveal, + !shouldPreserveVisibleOnTransientGeometry, + !frameNeedsUpdate, + !willHide, + !willReveal, + let lastOutcome = entry.lastSynchronizedOutcome, + Self.syncOutcomeApproximatelyEqual(lastOutcome, nextOutcome) { + resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) +#if DEBUG + dlog( + "portal.sync.skip hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " + + "hostWin=\(hostView.window?.windowNumber ?? -1) reason=unchanged " + + "target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + + "entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0)" + ) +#endif + entry.lastSynchronizedOutcome = nextOutcome + entriesByHostedId[hostedId] = entry + return .settled + } + // Hide before updating the frame when this entry should not be visible. // This avoids a one-frame flash of unrendered terminal background when a portal // briefly transitions through offscreen/tiny geometry during rapid split churn. @@ -1432,7 +1816,6 @@ final class WindowTerminalPortal: NSObject { } if hasFiniteFrame { - let expectedBounds = NSRect(origin: .zero, size: targetFrame.size) var geometryChanged = false CATransaction.begin() CATransaction.setDisableActions(true) @@ -1484,6 +1867,17 @@ final class WindowTerminalPortal: NSObject { resetTransientRecoveryRetryIfNeeded(forHostedId: hostedId, entry: &entry) } + entry.lastSynchronizedOutcome = SyncOutcome( + anchorId: ObjectIdentifier(anchorView), + rawFrame: frameInHost, + targetFrame: targetFrame, + hostBounds: hostBounds, + shouldHide: shouldHide, + visibleInUI: entry.visibleInUI, + hostedHidden: hostedView.isHidden + ) + entriesByHostedId[hostedId] = entry + #if DEBUG dlog( "portal.sync.result hosted=\(portalDebugToken(hostedView)) " + @@ -1497,6 +1891,7 @@ final class WindowTerminalPortal: NSObject { #endif ensureDividerOverlayOnTop() + return transientRecoveryReason == nil ? .settled : .transient } private func pruneDeadEntries() { @@ -1797,14 +2192,41 @@ enum TerminalWindowPortalRegistry { } let nextPortal = portal(for: window) + let isCrossWindowRebind: Bool if let oldWindowId = hostedToWindowId[hostedId], oldWindowId != windowId { + isCrossWindowRebind = true +#if DEBUG + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "crossWindowRebindStart", + fields: [ + "hostedId": portalDebugToken(hostedView), + "sourceWindowId": String(describing: oldWindowId), + "destinationWindowId": String(describing: windowId), + "visibleInUI": visibleInUI ? "1" : "0", + ] + ) +#endif portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId) + } else { + isCrossWindowRebind = false } nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority) hostedToWindowId[hostedId] = windowId +#if DEBUG + if isCrossWindowRebind, hostedView.window === window { + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "crossWindowRebindRecovered", + fields: [ + "hostedId": portalDebugToken(hostedView), + "destinationWindowId": String(describing: windowId), + "visibleInUI": visibleInUI ? "1" : "0", + ] + ) + } +#endif pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds()) } @@ -1815,6 +2237,9 @@ enum TerminalWindowPortalRegistry { } static func scheduleExternalGeometrySynchronize(for window: NSWindow) { +#if DEBUG + dlog("portal.geometry.external window=\(window.windowNumber)") +#endif existingPortal(for: window)?.scheduleExternalGeometrySynchronize() } @@ -1830,11 +2255,21 @@ enum TerminalWindowPortalRegistry { guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } Self.hasPendingExternalGeometrySyncForAllWindows = true let isDragEvent = Self.isInteractiveGeometryResizeActive +#if DEBUG + let windowList = Self.portalsByWindowId.keys.map { "\($0)" }.sorted().joined(separator: ",") + dlog( + "portal.geometry.externalAll windows=[\(windowList)] drag=\(isDragEvent ? 1 : 0)" + ) +#endif DispatchQueue.main.async { let performSync = { Self.hasPendingExternalGeometrySyncForAllWindows = false +#if DEBUG + let windowList = Self.portalsByWindowId.keys.map { "\($0)" }.sorted().joined(separator: ",") + dlog("portal.geometry.syncAll windows=[\(windowList)]") +#endif for portal in Self.portalsByWindowId.values { - portal.synchronizeAllEntriesFromExternalGeometryChange() + portal.scheduleExternalGeometrySynchronize() } } if isDragEvent { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4762046439de..7763a44a62bb 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -26,18 +26,6 @@ func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { return nil } - // Best-effort check: reject pointers that are not the start of a live - // malloc allocation. ghostty_surface_quicklook_font returns an unretained - // pointer whose lifetime is managed by Ghostty; on Intel Macs the pointer - // can become stale after the internal font is freed (#1496, #1870). - // malloc_size is safe to call with any address (returns 0 for non-malloc - // pointers without dereferencing them). This does not guarantee the memory - // still contains a valid CTFont, but it catches the common case of - // fully-freed or unmapped allocations that would otherwise SIGSEGV. - guard malloc_size(quicklookFont) > 0 else { - return nil - } - let ctFont = Unmanaged.fromOpaque(quicklookFont).takeUnretainedValue() let points = Float(CTFontGetSize(ctFont)) guard points > 0 else { return nil } @@ -127,22 +115,13 @@ private enum RemoteDropUploadError: LocalizedError { var errorDescription: String? { switch self { case .unavailable: - String( - localized: "error.remoteDrop.unavailable", - defaultValue: "Remote drop is unavailable." - ) + String(localized: "error.remoteDrop.unavailable", defaultValue: "Remote drop is unavailable.") case .invalidFileURL: + String(localized: "error.remoteDrop.invalidFileURL", defaultValue: "Dropped item is not a file URL.") + case let .uploadFailed(detail): String( - localized: "error.remoteDrop.invalidFileURL", - defaultValue: "Dropped item is not a file URL." - ) - case .uploadFailed(let detail): - String.localizedStringWithFormat( - String( - localized: "error.remoteDrop.uploadFailed", - defaultValue: "Failed to upload dropped file: %@" - ), - detail + localized: "error.remoteDrop.uploadFailed", + defaultValue: "Failed to upload dropped file: \(detail)" ) } } @@ -2967,25 +2946,6 @@ final class WorkspaceRemoteSessionController { queue.setSpecific(key: queueKey, value: ()) } - func start() { - debugLog("remote.session.start \(debugConfigSummary())") - queue.async { [weak self] in - guard let self else { return } - guard !self.isStopping else { return } - self.beginConnectionAttemptLocked() - } - } - - func stop() { - if DispatchQueue.getSpecific(key: queueKey) != nil { - stopAllLocked() - return - } - queue.async { [self] in - stopAllLocked() - } - } - func uploadDroppedFiles( _ fileURLs: [URL], operation: TerminalImageTransferOperation, @@ -3038,6 +2998,25 @@ final class WorkspaceRemoteSessionController { ) } + func start() { + debugLog("remote.session.start \(debugConfigSummary())") + queue.async { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + self.beginConnectionAttemptLocked() + } + } + + func stop() { + if DispatchQueue.getSpecific(key: queueKey) != nil { + stopAllLocked() + return + } + queue.async { [self] in + stopAllLocked() + } + } + private func stopAllLocked() { debugLog("remote.session.stop \(debugConfigSummary())") isStopping = true @@ -3535,17 +3514,12 @@ final class WorkspaceRemoteSessionController { ) } - private func scpExec( - arguments: [String], - timeout: TimeInterval = 30, - operation: TerminalImageTransferOperation? = nil - ) throws -> CommandResult { + private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult { try runProcess( executable: "/usr/bin/scp", arguments: arguments, stdin: nil, - timeout: timeout, - operation: operation + timeout: timeout ) } @@ -3555,8 +3529,7 @@ final class WorkspaceRemoteSessionController { environment: [String: String]? = nil, currentDirectory: URL? = nil, stdin: Data?, - timeout: TimeInterval, - operation: TerminalImageTransferOperation? = nil + timeout: TimeInterval ) throws -> CommandResult { debugLog( "remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + @@ -3611,7 +3584,6 @@ final class WorkspaceRemoteSessionController { } do { - try operation?.throwIfCancelled() try process.run() } catch { try? stdoutPipe.fileHandleForWriting.close() @@ -3626,34 +3598,20 @@ final class WorkspaceRemoteSessionController { } try? stdoutPipe.fileHandleForWriting.close() try? stderrPipe.fileHandleForWriting.close() - operation?.installCancellationHandler { - if process.isRunning { - process.terminate() - } - } - defer { operation?.clearCancellationHandler() } if let stdin, let pipe = process.standardInput as? Pipe { pipe.fileHandleForWriting.write(stdin) try? pipe.fileHandleForWriting.close() } - func terminateProcessAndWait() { + let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success + if !didExitBeforeTimeout, process.isRunning { process.terminate() let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success if !terminatedGracefully, process.isRunning { _ = Darwin.kill(process.processIdentifier, SIGKILL) process.waitUntilExit() } - } - - let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success - if !didExitBeforeTimeout, process.isRunning { - if operation?.isCancelled == true { - terminateProcessAndWait() - throw TerminalImageTransferExecutionError.cancelled - } - terminateProcessAndWait() debugLog( "remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" @@ -3668,9 +3626,6 @@ final class WorkspaceRemoteSessionController { try? stderrHandle.close() let stdout = String(data: stdoutData, encoding: .utf8) ?? "" let stderr = String(data: stderrData, encoding: .utf8) ?? "" - if operation?.isCancelled == true { - throw TerminalImageTransferExecutionError.cancelled - } debugLog( "remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + "status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " + @@ -3866,6 +3821,70 @@ final class WorkspaceRemoteSessionController { return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) } + static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String { + let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())" + return "/tmp/cmux-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)" + } + + private func uploadDroppedFilesLocked( + _ fileURLs: [URL], + operation: TerminalImageTransferOperation + ) throws -> [String] { + guard !fileURLs.isEmpty else { return [] } + + let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) + var uploadedRemotePaths: [String] = [] + do { + for localURL in fileURLs { + try operation.throwIfCancelled() + let normalizedLocalURL = localURL.standardizedFileURL + guard normalizedLocalURL.isFileURL else { + throw RemoteDropUploadError.invalidFileURL + } + + let remotePath = Self.remoteDropPath(for: normalizedLocalURL) + uploadedRemotePaths.append(remotePath) + var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"] + if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { + scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] + } + if let port = configuration.port { + scpArgs += ["-P", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + scpArgs += ["-i", identityFile] + } + for option in scpSSHOptions { + scpArgs += ["-o", option] + } + scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"] + + let scpResult = try scpExec(arguments: scpArgs, timeout: 45) + guard scpResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) + ?? "scp exited \(scpResult.status)" + throw RemoteDropUploadError.uploadFailed(detail) + } + } + return uploadedRemotePaths + } catch { + cleanupUploadedRemotePaths(uploadedRemotePaths) + throw error + } + } + + private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { + guard !remotePaths.isEmpty else { return } + let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ") + let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))" + _ = try? sshExec( + arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand], + timeout: 8 + ) + } + private static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? { remoteDaemonManifest(from: Bundle.main.infoDictionary) } @@ -4122,70 +4141,6 @@ final class WorkspaceRemoteSessionController { } } - private func uploadDroppedFilesLocked( - _ fileURLs: [URL], - operation: TerminalImageTransferOperation - ) throws -> [String] { - guard !fileURLs.isEmpty else { return [] } - - let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) - var uploadedRemotePaths: [String] = [] - do { - for localURL in fileURLs { - try operation.throwIfCancelled() - let normalizedLocalURL = localURL.standardizedFileURL - guard normalizedLocalURL.isFileURL else { - throw RemoteDropUploadError.invalidFileURL - } - - let remotePath = Self.remoteDropPath(for: normalizedLocalURL) - uploadedRemotePaths.append(remotePath) - var scpArgs: [String] = ["-q", "-o", "ControlMaster=no"] - if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { - scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] - } - if let port = configuration.port { - scpArgs += ["-P", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - scpArgs += ["-i", identityFile] - } - for option in scpSSHOptions { - scpArgs += ["-o", option] - } - scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"] - - let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation) - guard scpResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? - "scp exited \(scpResult.status)" - throw RemoteDropUploadError.uploadFailed(detail) - } - } - return uploadedRemotePaths - } catch { - cleanupUploadedRemotePaths(uploadedRemotePaths) - throw error - } - } - - static func remoteDropPath(for fileURL: URL, uuid: UUID = UUID()) -> String { - let extensionSuffix = fileURL.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) - let lowercasedSuffix = extensionSuffix.isEmpty ? "" : ".\(extensionSuffix.lowercased())" - return "/tmp/cmux-drop-\(uuid.uuidString.lowercased())\(lowercasedSuffix)" - } - - private func cleanupUploadedRemotePaths(_ remotePaths: [String]) { - guard !remotePaths.isEmpty else { return } - let cleanupScript = "rm -f -- " + remotePaths.map(Self.shellSingleQuoted).joined(separator: " ") - let cleanupCommand = "sh -c \(Self.shellSingleQuoted(cleanupScript))" - _ = try? sshExec( - arguments: sshCommonArguments(batchMode: true) + [configuration.destination, cleanupCommand], - timeout: 8 - ) - } - private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { let request = #"{"id":1,"method":"hello","params":{}}"# let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio" @@ -4736,154 +4691,6 @@ enum SidebarBranchOrdering { let directory: String? } - fileprivate static func normalizedDirectory(_ text: String?) -> String? { - guard let text else { return nil } - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private static func relativePathFromTilde(_ directory: String) -> String? { - let normalized = normalizedDirectory(directory) - switch normalized { - case "~": - return "" - case let path? where path.hasPrefix("~/"): - return String(path.dropFirst(2)) - default: - return nil - } - } - - private static func commonHomeDirectoryPrefix(from absoluteDirectory: String) -> String? { - guard let normalized = normalizedDirectory(absoluteDirectory) else { return nil } - let standardized = NSString(string: normalized).standardizingPath - if standardized == "/root" || standardized.hasPrefix("/root/") { - return "/root" - } - - let components = NSString(string: standardized).pathComponents - if components.count >= 3, components[0] == "/", components[1] == "Users" { - return NSString.path(withComponents: Array(components.prefix(3))) - } - if components.count >= 3, components[0] == "/", components[1] == "home" { - return NSString.path(withComponents: Array(components.prefix(3))) - } - if components.count >= 4, components[0] == "/", components[1] == "var", components[2] == "home" { - return NSString.path(withComponents: Array(components.prefix(4))) - } - - return nil - } - - private static func inferredHomeDirectory( - matchingTildeDirectory tildeDirectory: String, - absoluteDirectory: String - ) -> String? { - guard let relativePath = relativePathFromTilde(tildeDirectory), - let normalizedAbsolute = normalizedDirectory(absoluteDirectory) else { return nil } - let standardizedAbsolute = NSString(string: normalizedAbsolute).standardizingPath - let homeDirectory: String - if relativePath.isEmpty { - homeDirectory = standardizedAbsolute - } else { - let suffix = "/" + relativePath - guard standardizedAbsolute.hasSuffix(suffix) else { return nil } - homeDirectory = String(standardizedAbsolute.dropLast(suffix.count)) - } - - guard commonHomeDirectoryPrefix(from: homeDirectory) == homeDirectory else { return nil } - return homeDirectory - } - - fileprivate static func inferredRemoteHomeDirectory( - from directories: [String], - fallbackDirectory: String? - ) -> String? { - let candidates = directories + [fallbackDirectory].compactMap { $0 } - let tildeDirectories = candidates.compactMap { directory -> String? in - guard let normalized = normalizedDirectory(directory), - relativePathFromTilde(normalized) != nil else { return nil } - return normalized - } - let absoluteDirectories = candidates.compactMap { directory -> String? in - guard let normalized = normalizedDirectory(directory), normalized.hasPrefix("/") else { return nil } - return NSString(string: normalized).standardizingPath - } - - let inferredHomes = Set( - tildeDirectories.flatMap { tildeDirectory in - absoluteDirectories.compactMap { absoluteDirectory in - inferredHomeDirectory( - matchingTildeDirectory: tildeDirectory, - absoluteDirectory: absoluteDirectory - ) - } - } - ) - - if inferredHomes.count == 1 { - return inferredHomes.first - } - if !inferredHomes.isEmpty { - return nil - } - - return absoluteDirectories.lazy.compactMap(commonHomeDirectoryPrefix(from:)).first - } - - private static func expandedTildePath( - _ directory: String, - homeDirectoryForTildeExpansion: String? - ) -> String { - guard let relativePath = relativePathFromTilde(directory), - let homeDirectory = normalizedDirectory(homeDirectoryForTildeExpansion) else { - return directory - } - if relativePath.isEmpty { - return homeDirectory - } - return NSString(string: homeDirectory).appendingPathComponent(relativePath) - } - - fileprivate static func canonicalDirectoryKey( - _ directory: String?, - homeDirectoryForTildeExpansion: String? - ) -> String? { - guard let directory = normalizedDirectory(directory) else { return nil } - let expanded = expandedTildePath( - directory, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion - ) - let standardized = NSString(string: expanded).standardizingPath - let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) - return cleaned.isEmpty ? nil : cleaned - } - - private static func preferredDisplayedDirectory( - existing: String?, - replacement: String?, - homeDirectoryForTildeExpansion: String? - ) -> String? { - guard let replacement = normalizedDirectory(replacement) else { return existing } - guard let existing = normalizedDirectory(existing) else { return replacement } - - let existingUsesTilde = relativePathFromTilde(existing) != nil - let replacementUsesTilde = relativePathFromTilde(replacement) != nil - if existingUsesTilde != replacementUsesTilde { - return replacementUsesTilde ? existing : replacement - } - - if canonicalDirectoryKey(existing, homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion) - == canonicalDirectoryKey( - replacement, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion - ) { - return existing - } - - return replacement - } - static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { switch tree { case .pane(let pane): @@ -5028,7 +4835,6 @@ enum SidebarBranchOrdering { panelBranches: [UUID: SidebarGitBranchState], panelDirectories: [UUID: String], defaultDirectory: String?, - homeDirectoryForTildeExpansion: String?, fallbackBranch: SidebarGitBranchState? ) -> [BranchDirectoryEntry] { struct EntryKey: Hashable { @@ -5042,7 +4848,20 @@ enum SidebarBranchOrdering { var directory: String? } - let normalized = normalizedDirectory + func normalized(_ text: String?) -> String? { + guard let text else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func canonicalDirectoryKey(_ directory: String?) -> String? { + guard let directory = normalized(directory) else { return nil } + let expanded = NSString(string: directory).expandingTildeInPath + let standardized = NSString(string: expanded).standardizingPath + let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } + let normalizedFallbackBranch = normalized(fallbackBranch?.branch) let shouldUseFallbackBranchPerPanel = !orderedPanelIds.contains { normalized(panelBranches[$0]?.branch) != nil @@ -5056,7 +4875,7 @@ enum SidebarBranchOrdering { for panelId in orderedPanelIds { let panelBranch = normalized(panelBranches[panelId]?.branch) let branch = panelBranch ?? defaultBranchForPanels - let directory = normalized(panelDirectories[panelId]) + let directory = normalized(panelDirectories[panelId] ?? defaultDirectory) guard branch != nil || directory != nil else { continue } let panelDirty = panelBranch != nil @@ -5064,10 +4883,7 @@ enum SidebarBranchOrdering { : defaultBranchDirty let key: EntryKey - if let directoryKey = canonicalDirectoryKey( - directory, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion - ) { + if let directoryKey = canonicalDirectoryKey(directory) { // Keep one line per directory and allow the latest branch state to overwrite. key = EntryKey(directory: directoryKey, branch: nil) } else { @@ -5084,11 +4900,9 @@ enum SidebarBranchOrdering { } else if existing.branch == nil { existing.isDirty = panelDirty } - existing.directory = preferredDisplayedDirectory( - existing: existing.directory, - replacement: directory, - homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion - ) + if let directory { + existing.directory = directory + } entries[key] = existing } else if panelDirty { existing.isDirty = true @@ -5213,10 +5027,6 @@ final class Workspace: Identifiable, ObservableObject { @Published private(set) var panelCustomTitles: [UUID: String] = [:] @Published private(set) var pinnedPanelIds: Set = [] @Published private(set) var manualUnreadPanelIds: Set = [] - @Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot? - @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? - @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? - @Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0 private var manualUnreadMarkedAt: [UUID: Date] = [:] nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 @@ -5453,7 +5263,6 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: initialTerminalCommand, initialEnvironmentOverrides: initialTerminalEnvironment ) - configureTerminalPanel(terminalPanel) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) @@ -5504,7 +5313,6 @@ final class Workspace: Identifiable, ObservableObject { } bonsplitController.selectTab(initialTabId) } - tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() } deinit { @@ -5562,6 +5370,9 @@ final class Workspace: Identifiable, ObservableObject { private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 private var debugLastDidMoveTabTimestamp: TimeInterval = 0 private var debugDidMoveTabEventCount: UInt64 = 0 + private var debugLayoutFollowUpEventCount: UInt64 = 0 + private var debugTerminalGeometryPassCount: UInt64 = 0 + private var debugPendingVisibleDetachedPanelIds: Set = [] #endif private var layoutFollowUpObservers: [NSObjectProtocol] = [] private var layoutFollowUpPanelsCancellable: AnyCancellable? @@ -5573,6 +5384,9 @@ final class Workspace: Identifiable, ObservableObject { private var layoutFollowUpNeedsGeometryPass = false private var layoutFollowUpAttemptScheduled = false private var layoutFollowUpStalledAttemptCount = 0 + private var layoutFollowUpDeferredBonsplitGeometryReason: String? + private var layoutFollowUpDeferredBonsplitGeometryGeneration: UInt64 = 0 + private var layoutFollowUpDeferredBonsplitGeometryFallbackWorkItem: DispatchWorkItem? private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? @@ -5594,12 +5408,9 @@ final class Workspace: Identifiable, ObservableObject { let isLoading: Bool let isPinned: Bool let directory: String? - let ttyName: String? let cachedTitle: String? let customTitle: String? let manuallyUnread: Bool - let isRemoteTerminal: Bool - let remoteRelayPort: Int? } private var detachingTabIds: Set = [] @@ -5626,19 +5437,6 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId.first { $0.value == panelId }?.key } - private func configureTerminalPanel(_ terminalPanel: TerminalPanel) { - terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in - guard let self, let terminalPanel else { return } - self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason) - } - } - - private func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { - tmuxWorkspaceFlashPanelId = panelId - tmuxWorkspaceFlashReason = reason - tmuxWorkspaceFlashToken &+= 1 - } - private func installBrowserPanelSubscription(_ browserPanel: BrowserPanel) { let subscription = Publishers.CombineLatest3( @@ -5800,31 +5598,7 @@ final class Workspace: Identifiable, ObservableObject { } private func hasUnreadNotification(panelId: UUID) -> Bool { - AppDelegate.shared?.notificationStore?.hasVisibleNotificationIndicator(forTabId: id, surfaceId: panelId) ?? false - } - - private func attentionPersistentState() -> WorkspaceAttentionPersistentState { - let notificationStore = AppDelegate.shared?.notificationStore - let unreadPanelIDs = Set( - panels.keys.filter { - notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: $0) ?? false - } - ) - return WorkspaceAttentionPersistentState( - unreadPanelIDs: unreadPanelIDs, - focusedReadPanelID: notificationStore?.focusedReadIndicatorSurfaceId(forTabId: id), - manualUnreadPanelIDs: manualUnreadPanelIds - ) - } - - private func requestAttentionFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { - let decision = WorkspaceAttentionCoordinator.decideFlash( - targetPanelID: panelId, - reason: reason, - persistentState: attentionPersistentState() - ) - guard decision.isAllowed else { return } - panels[panelId]?.triggerFlash(reason: reason) + AppDelegate.shared?.notificationStore?.hasUnreadNotification(forTabId: id, surfaceId: panelId) ?? false } private func syncUnreadBadgeStateForPanel(_ panelId: UUID) { @@ -5932,14 +5706,14 @@ final class Workspace: Identifiable, ObservableObject { if isVisibleSelection { terminalPanel.requestViewReattach() - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile(reason: "workspace.debugStress.preload") } terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() return terminalPanel } func scheduleDebugStressTerminalGeometryReconcile() { - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile(reason: "workspace.debugStress.manual") } func hasLoadedTerminalSurface() -> Bool { @@ -6301,77 +6075,6 @@ final class Workspace: Identifiable, ObservableObject { ) } - private func normalizedSidebarDirectory(_ directory: String?) -> String? { - guard let directory else { return nil } - let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private func sidebarHomeDirectoryForCanonicalization( - resolvedPanelDirectories: [UUID: String] - ) -> String? { - if isRemoteWorkspace { - return SidebarBranchOrdering.inferredRemoteHomeDirectory( - from: Array(resolvedPanelDirectories.values), - fallbackDirectory: normalizedSidebarDirectory(currentDirectory) - ) - } - return FileManager.default.homeDirectoryForCurrentUser.path - } - - private func sidebarResolvedDirectory(for panelId: UUID) -> String? { - if let directory = normalizedSidebarDirectory(panelDirectories[panelId]) { - return directory - } - if let requestedDirectory = normalizedSidebarDirectory( - terminalPanel(for: panelId)?.requestedWorkingDirectory - ) { - return requestedDirectory - } - guard panelId == focusedPanelId else { return nil } - return normalizedSidebarDirectory(currentDirectory) - } - - private func sidebarResolvedPanelDirectories(orderedPanelIds: [UUID]) -> [UUID: String] { - var resolved: [UUID: String] = [:] - for panelId in orderedPanelIds { - if let directory = sidebarResolvedDirectory(for: panelId) { - resolved[panelId] = directory - } - } - return resolved - } - - func sidebarDirectoriesInDisplayOrder(orderedPanelIds: [UUID]) -> [String] { - let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) - let homeDirectoryForCanonicalization = sidebarHomeDirectoryForCanonicalization( - resolvedPanelDirectories: resolvedDirectories - ) - var ordered: [String] = [] - var seen: Set = [] - - for panelId in orderedPanelIds { - guard let directory = resolvedDirectories[panelId], - let key = SidebarBranchOrdering.canonicalDirectoryKey( - directory, - homeDirectoryForTildeExpansion: homeDirectoryForCanonicalization - ) else { continue } - if seen.insert(key).inserted { - ordered.append(directory) - } - } - - if ordered.isEmpty, let fallbackDirectory = normalizedSidebarDirectory(currentDirectory) { - return [fallbackDirectory] - } - - return ordered - } - - func sidebarDirectoriesInDisplayOrder() -> [String] { - sidebarDirectoriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) - } - func sidebarGitBranchesInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarGitBranchState] { SidebarBranchOrdering .orderedUniqueBranches( @@ -6389,15 +6092,11 @@ final class Workspace: Identifiable, ObservableObject { func sidebarBranchDirectoryEntriesInDisplayOrder( orderedPanelIds: [UUID] ) -> [SidebarBranchOrdering.BranchDirectoryEntry] { - let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) - return SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( orderedPanelIds: orderedPanelIds, panelBranches: panelGitBranches, - panelDirectories: resolvedDirectories, - defaultDirectory: normalizedSidebarDirectory(currentDirectory), - homeDirectoryForTildeExpansion: sidebarHomeDirectoryForCanonicalization( - resolvedPanelDirectories: resolvedDirectories - ), + panelDirectories: panelDirectories, + defaultDirectory: currentDirectory, fallbackBranch: gitBranch ) } @@ -6444,11 +6143,6 @@ final class Workspace: Identifiable, ObservableObject { remoteConfiguration != nil } - @MainActor - func isRemoteTerminalSurface(_ panelId: UUID) -> Bool { - activeRemoteTerminalSurfaceIds.contains(panelId) - } - var remoteDisplayTarget: String? { remoteConfiguration?.displayTarget } @@ -6654,6 +6348,11 @@ final class Workspace: Identifiable, ObservableObject { activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count } + @MainActor + func isRemoteTerminalSurface(_ panelId: UUID) -> Bool { + activeRemoteTerminalSurfaceIds.contains(panelId) + } + private func untrackRemoteTerminalSurface(_ panelId: UUID) { guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count @@ -6852,8 +6551,7 @@ final class Workspace: Identifiable, ObservableObject { private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { lastTerminalConfigInheritancePanelId = terminalPanel.id - if terminalPanel.surface.hasLiveSurface, - let sourceSurface = terminalPanel.surface.surface, + if let sourceSurface = terminalPanel.surface.surface, let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { @@ -6951,8 +6649,7 @@ final class Workspace: Identifiable, ObservableObject { preferredPanelId: preferredPanelId, inPane: preferredPaneId ) { - guard terminalPanel.surface.hasLiveSurface, - let sourceSurface = terminalPanel.surface.surface else { continue } + guard let sourceSurface = terminalPanel.surface.surface else { continue } var config = cmuxInheritedSurfaceConfig( sourceSurface: sourceSurface, context: GHOSTTY_SURFACE_CONTEXT_SPLIT @@ -7041,7 +6738,6 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: portOrdinal, initialCommand: remoteTerminalStartupCommand ) - configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle if remoteTerminalStartupCommand != nil { @@ -7131,7 +6827,6 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: remoteTerminalStartupCommand, additionalEnvironment: startupEnvironment ) - configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle if remoteTerminalStartupCommand != nil { @@ -7830,7 +7525,7 @@ final class Workspace: Identifiable, ObservableObject { } else { scheduleFocusReconcile() } - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile(reason: "workspace.moveSurface") return true } @@ -7844,7 +7539,7 @@ final class Workspace: Identifiable, ObservableObject { } else { scheduleFocusReconcile() } - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile(reason: "workspace.reorderSurface") return true } @@ -7853,6 +7548,7 @@ final class Workspace: Identifiable, ObservableObject { guard panels[panelId] != nil else { return nil } #if DEBUG let detachStart = ProcessInfo.processInfo.systemUptime + debugPendingVisibleDetachedPanelIds.remove(panelId) dlog( "split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + "tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " + @@ -7938,11 +7634,6 @@ final class Workspace: Identifiable, ObservableObject { if let directory = detached.directory { panelDirectories[detached.panelId] = directory } - if let ttyName = detached.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { - surfaceTTYNames[detached.panelId] = ttyName - } else { - surfaceTTYNames.removeValue(forKey: detached.panelId) - } if let cachedTitle = detached.cachedTitle { panelTitles[detached.panelId] = cachedTitle } @@ -7975,7 +7666,6 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: detached.panelId) panelDirectories.removeValue(forKey: detached.panelId) - surfaceTTYNames.removeValue(forKey: detached.panelId) panelTitles.removeValue(forKey: detached.panelId) panelCustomTitles.removeValue(forKey: detached.panelId) pinnedPanelIds.remove(detached.panelId) @@ -7992,11 +7682,6 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = detached.panelId - if detached.isRemoteTerminal, - let detachedRelayPort = detached.remoteRelayPort, - detachedRelayPort == remoteConfiguration?.relayPort { - trackRemoteTerminalSurface(detached.panelId) - } if let index { _ = bonsplitController.reorderTab(newTabId, toIndex: index) } @@ -8250,10 +7935,8 @@ final class Workspace: Identifiable, ObservableObject { } func moveFocus(direction: NavigationDirection) { - let previousFocusedPanelId = focusedPanelId - // Unfocus the currently-focused panel before navigating. - if let prevPanelId = previousFocusedPanelId, let prev = panels[prevPanelId] { + if let prevPanelId = focusedPanelId, let prev = panels[prevPanelId] { prev.unfocus() } @@ -8265,7 +7948,6 @@ final class Workspace: Identifiable, ObservableObject { let tabId = bonsplitController.selectedTab(inPane: paneId)?.id { applyTabSelection(tabId: tabId, inPane: paneId) } - } // MARK: - Surface Navigation @@ -8370,7 +8052,7 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Flash/Notification Support func triggerFocusFlash(panelId: UUID) { - requestAttentionFlash(panelId: panelId, reason: .navigation) + panels[panelId]?.triggerFlash() } func triggerNotificationFocusFlash( @@ -8378,7 +8060,7 @@ final class Workspace: Identifiable, ObservableObject { requiresSplit: Bool = false, shouldFocus: Bool = true ) { - guard terminalPanel(for: panelId) != nil else { return } + guard let terminalPanel = terminalPanel(for: panelId) else { return } if shouldFocus { focusPanel(panelId) } @@ -8386,18 +8068,16 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - requestAttentionFlash(panelId: panelId, reason: .notificationArrival) + terminalPanel.triggerFlash() } func triggerNotificationDismissFlash(panelId: UUID) { - guard terminalPanel(for: panelId) != nil else { return } - requestAttentionFlash(panelId: panelId, reason: .notificationDismiss) + guard let terminalPanel = terminalPanel(for: panelId) else { return } + terminalPanel.triggerFlash() } func triggerDebugFlash(panelId: UUID) { - guard panels[panelId] != nil else { return } - focusPanel(panelId) - requestAttentionFlash(panelId: panelId, reason: .debug) + triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: true) } // MARK: - Portal Lifecycle @@ -8435,7 +8115,6 @@ final class Workspace: Identifiable, ObservableObject { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) - configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) @@ -8543,6 +8222,7 @@ final class Workspace: Identifiable, ObservableObject { terminalFocusPanelId: UUID? = nil, includeGeometry: Bool = false ) { + let alreadyActive = layoutFollowUpTimeoutWorkItem != nil layoutFollowUpReason = reason if let browserPanelId { layoutFollowUpBrowserPanelId = browserPanelId @@ -8556,10 +8236,35 @@ final class Workspace: Identifiable, ObservableObject { layoutFollowUpNeedsGeometryPass = layoutFollowUpNeedsGeometryPass || includeGeometry layoutFollowUpStalledAttemptCount = 0 +#if DEBUG + debugLayoutFollowUpEventCount &+= 1 + let eventId = debugLayoutFollowUpEventCount + dlog( + "layout.followUp.begin id=\(eventId) ws=\(id.uuidString.prefix(5)) " + + "reason=\(reason) geometry=\(includeGeometry ? 1 : 0) " + + "terminalFocus=\(terminalFocusPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil") " + + "browser=\(browserPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil") " + + "browserExit=\(browserExitFocusPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil") " + + "focusedPane=\(bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil") " + + "focusedPanel=\(focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil") " + + "layout=\(debugPaneSelectionSummary())" + ) +#endif + if layoutFollowUpTimeoutWorkItem == nil { installLayoutFollowUpObservers() } refreshLayoutFollowUpTimeout() + + // When a follow-up cycle is already active, merge this request into the existing + // cycle and let the scheduled retry handle it. This avoids back-to-back immediate + // geometry passes when Bonsplit emits multiple didChangeGeometry callbacks during + // the same split add/close transition. + if alreadyActive { + scheduleLayoutFollowUpAttempt() + return + } + attemptEventDrivenLayoutFollowUp() } @@ -8657,6 +8362,13 @@ final class Workspace: Identifiable, ObservableObject { layoutFollowUpAttemptScheduled = true let delay = layoutFollowUpBackoffDelay() +#if DEBUG + dlog( + "layout.followUp.retry.schedule ws=\(id.uuidString.prefix(5)) " + + "delayMs=\(String(format: "%.1f", delay * 1000)) stalled=\(layoutFollowUpStalledAttemptCount) " + + "reason=\(layoutFollowUpReason ?? "nil")" + ) +#endif DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self else { return } self.layoutFollowUpAttemptScheduled = false @@ -8736,6 +8448,17 @@ final class Workspace: Identifiable, ObservableObject { let browserPanelPendingBefore = browserPanelNeedsFollowUp() let browserExitPendingBefore = layoutFollowUpBrowserExitFocusPanelId != nil +#if DEBUG + let debugReason = layoutFollowUpReason ?? "workspace.layout" + dlog( + "layout.followUp.attempt.begin ws=\(id.uuidString.prefix(5)) reason=\(debugReason) " + + "geometry=\(geometryPendingBefore ? 1 : 0) terminalPortal=\(terminalPortalPendingBefore ? 1 : 0) " + + "browserVisibility=\(browserVisibilityPendingBefore ? 1 : 0) terminalFocus=\(terminalFocusPendingBefore ? 1 : 0) " + + "browserPanel=\(browserPanelPendingBefore ? 1 : 0) browserExit=\(browserExitPendingBefore ? 1 : 0) " + + "layout=\(debugPaneSelectionSummary())" + ) +#endif + if layoutFollowUpNeedsGeometryPass { layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() } @@ -8807,6 +8530,12 @@ final class Workspace: Identifiable, ObservableObject { browserExitPending if !needsMoreWork { +#if DEBUG + dlog( + "layout.followUp.attempt.end ws=\(id.uuidString.prefix(5)) reason=\(reason) " + + "needsMore=0 layout=\(debugPaneSelectionSummary())" + ) +#endif clearLayoutFollowUp() return } @@ -8825,12 +8554,34 @@ final class Workspace: Identifiable, ObservableObject { } else { layoutFollowUpStalledAttemptCount += 1 } + +#if DEBUG + dlog( + "layout.followUp.attempt.end ws=\(id.uuidString.prefix(5)) reason=\(reason) " + + "needsMore=1 geometry=\(layoutFollowUpNeedsGeometryPass ? 1 : 0) " + + "terminalPortal=\(terminalPortalPending ? 1 : 0) browserVisibility=\(browserVisibilityPending ? 1 : 0) " + + "terminalFocus=\(terminalFocusPending ? 1 : 0) browserPanel=\(browserPanelPending ? 1 : 0) " + + "browserExit=\(browserExitPending ? 1 : 0) progress=\(didMakeProgress ? 1 : 0) " + + "stalled=\(layoutFollowUpStalledAttemptCount) layout=\(debugPaneSelectionSummary())" + ) +#endif } /// Reconcile remaining terminal view geometries after split topology changes. /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. private func reconcileTerminalGeometryPass() -> Bool { var needsFollowUpPass = false + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + +#if DEBUG + debugTerminalGeometryPassCount &+= 1 + let passId = debugTerminalGeometryPassCount + dlog( + "geometry.pass.begin id=\(passId) ws=\(id.uuidString.prefix(5)) " + + "reason=\(layoutFollowUpReason ?? "nil") visible=\(debugShortPanelIds(visiblePanelIds)) " + + "layout=\(debugPaneSelectionSummary())" + ) +#endif // Flush pending AppKit layout first so terminal-host bounds reflect latest split topology. for window in NSApp.windows { @@ -8840,35 +8591,214 @@ final class Workspace: Identifiable, ObservableObject { for panel in panels.values { guard let terminalPanel = panel as? TerminalPanel else { continue } let hostedView = terminalPanel.hostedView + let wasVisible = visiblePanelIds.contains(terminalPanel.id) let hasUsableBounds = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 let hasSurface = terminalPanel.surface.surface != nil - let isAttached = hostedView.window != nil && hostedView.superview != nil + let hasWindow = hostedView.window != nil + let hasSuperview = hostedView.superview != nil + let isAttached = hasWindow && hasSuperview + let portalVisible = hostedView.debugPortalVisibleInUI + let visibleDetached = wasVisible && (!hasWindow || !hasSuperview) + let transientPortalRemount = wasVisible && portalVisible && !hasWindow && !hasSuperview + let requestedReattach = geometryNeedsReattach( + isAttached: isAttached, + hasUsableBounds: hasUsableBounds, + hasSurface: hasSurface + ) +#if DEBUG + let reattachReasons = debugGeometryReattachReasons( + isAttached: isAttached, + hasUsableBounds: hasUsableBounds, + hasSurface: hasSurface + ) + let beforeState = debugTerminalGeometryState(for: terminalPanel, visibleInLayout: wasVisible) +#endif + + if transientPortalRemount { +#if DEBUG + debugPendingVisibleDetachedPanelIds.insert(terminalPanel.id) + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "transientPortalRemount", + fields: [ + "panelId": terminalPanel.id.uuidString, + "workspaceId": id.uuidString, + "passId": String(passId), + "portalVisible": portalVisible ? "1" : "0", + "requestedReattach": "1", + "needsFollowUp": "1", + ] + ) +#endif + terminalPanel.requestViewReattach() + needsFollowUpPass = true +#if DEBUG + dlog( + "geometry.pass.defer id=\(passId) ws=\(id.uuidString.prefix(5)) " + + "panel=\(terminalPanel.id.uuidString.prefix(5)) reason=awaitingPortalHostWindow " + + "requestedReattach=1 needsFollowUp=1 " + + "state={\(beforeState)}" + ) +#endif + continue + } // Split close/reparent churn can transiently detach a surviving terminal view. // Force one SwiftUI representable update so the portal binding reattaches it. - if !isAttached || !hasUsableBounds || !hasSurface { + if requestedReattach { +#if DEBUG + if visibleDetached { + debugPendingVisibleDetachedPanelIds.insert(terminalPanel.id) + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "visibleDetached", + fields: [ + "panelId": terminalPanel.id.uuidString, + "workspaceId": id.uuidString, + "passId": String(passId), + "portalVisible": portalVisible ? "1" : "0", + "hasWindow": hasWindow ? "1" : "0", + "hasSuperview": hasSuperview ? "1" : "0", + ] + ) + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "reattachRequested", + fields: [ + "panelId": terminalPanel.id.uuidString, + "workspaceId": id.uuidString, + "passId": String(passId), + "portalVisible": portalVisible ? "1" : "0", + "needsFollowUp": "1", + ] + ) + } +#endif terminalPanel.requestViewReattach() needsFollowUpPass = true } - hostedView.reconcileGeometryNow() - // Re-check surface after reconcileGeometryNow() which can trigger AppKit - // layout and view lifecycle changes that free surfaces (#432). - if terminalPanel.surface.surface != nil { + let geometryChanged = hostedView.reconcileGeometryNow() + let hasSurfaceAfterReconcile = terminalPanel.surface.surface != nil + let didRefresh = geometryNeedsRefresh( + visibleInLayout: wasVisible, + requestedReattach: requestedReattach, + geometryChanged: geometryChanged, + hadSurfaceBeforeReconcile: hasSurface, + hasSurfaceAfterReconcile: hasSurfaceAfterReconcile + ) +#if DEBUG + let refreshReasons = debugGeometryRefreshReasons( + visibleInLayout: wasVisible, + requestedReattach: requestedReattach, + geometryChanged: geometryChanged, + hadSurfaceBeforeReconcile: hasSurface, + hasSurfaceAfterReconcile: hasSurfaceAfterReconcile + ) +#endif + if didRefresh { + // Keep the immediate redraw only for surfaces whose visible geometry actually changed. terminalPanel.surface.forceRefresh() } - if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { + + let isAttachedAfterReconcile = hostedView.window != nil && hostedView.superview != nil + let hasUsableBoundsAfterReconcile = hostedView.bounds.width > 1 && hostedView.bounds.height > 1 + let didRequestSurfaceStart = !hasSurfaceAfterReconcile && isAttachedAfterReconcile && hasUsableBoundsAfterReconcile + if didRequestSurfaceStart { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() needsFollowUpPass = true } + +#if DEBUG + if wasVisible, + isAttachedAfterReconcile, + hasUsableBoundsAfterReconcile, + debugPendingVisibleDetachedPanelIds.contains(terminalPanel.id) { + debugPendingVisibleDetachedPanelIds.remove(terminalPanel.id) + AppDelegate.shared?.recordUITestTerminalGeometryEvent( + kind: "reattachRecovered", + fields: [ + "panelId": terminalPanel.id.uuidString, + "workspaceId": id.uuidString, + "passId": String(passId), + "portalVisible": hostedView.debugPortalVisibleInUI ? "1" : "0", + ] + ) + } +#endif + +#if DEBUG + let afterState = debugTerminalGeometryState(for: terminalPanel, visibleInLayout: wasVisible) + if wasVisible || beforeState != afterState || requestedReattach || didRequestSurfaceStart { + dlog( + "geometry.pass.panel id=\(passId) ws=\(id.uuidString.prefix(5)) " + + "panel=\(terminalPanel.id.uuidString.prefix(5)) before={\(beforeState)} after={\(afterState)} " + + "requestedReattach=\(requestedReattach ? 1 : 0) geometryChanged=\(geometryChanged ? 1 : 0) " + + "refresh=\(didRefresh ? "force" : "skip") " + + "refreshReasons=\(refreshReasons.isEmpty ? "-" : refreshReasons.joined(separator: ",")) " + + "reattachReasons=\(reattachReasons.isEmpty ? "-" : reattachReasons.joined(separator: ",")) " + + "surfaceStart=\(didRequestSurfaceStart ? 1 : 0) needsFollowUp=\(needsFollowUpPass ? 1 : 0)" + ) + } +#endif } +#if DEBUG + dlog( + "geometry.pass.end id=\(passId) ws=\(id.uuidString.prefix(5)) " + + "needsFollowUp=\(needsFollowUpPass ? 1 : 0)" + ) +#endif return needsFollowUpPass } - private func scheduleTerminalGeometryReconcile() { + private func deferTerminalGeometryReconcileUntilBonsplitGeometryChange(reason: String) { + layoutFollowUpDeferredBonsplitGeometryGeneration &+= 1 + let generation = layoutFollowUpDeferredBonsplitGeometryGeneration + layoutFollowUpDeferredBonsplitGeometryReason = reason + layoutFollowUpDeferredBonsplitGeometryFallbackWorkItem?.cancel() +#if DEBUG + dlog( + "geometry.defer.begin ws=\(id.uuidString.prefix(5)) reason=\(reason) generation=\(generation)" + ) +#endif + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + guard self.layoutFollowUpDeferredBonsplitGeometryReason == reason, + self.layoutFollowUpDeferredBonsplitGeometryGeneration == generation else { + return + } + self.layoutFollowUpDeferredBonsplitGeometryReason = nil + self.layoutFollowUpDeferredBonsplitGeometryFallbackWorkItem = nil +#if DEBUG + dlog( + "geometry.defer.fallback ws=\(self.id.uuidString.prefix(5)) reason=\(reason) generation=\(generation)" + ) +#endif + self.scheduleTerminalGeometryReconcile(reason: "\(reason).fallback") + } + layoutFollowUpDeferredBonsplitGeometryFallbackWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02, execute: workItem) + } + + private func consumeDeferredBonsplitGeometryReason(defaultReason: String) -> String { + layoutFollowUpDeferredBonsplitGeometryFallbackWorkItem?.cancel() + layoutFollowUpDeferredBonsplitGeometryFallbackWorkItem = nil + guard let deferredReason = layoutFollowUpDeferredBonsplitGeometryReason else { + return defaultReason + } + layoutFollowUpDeferredBonsplitGeometryReason = nil + return "\(defaultReason)[\(deferredReason)]" + } + + private func scheduleTerminalGeometryReconcile( + reason: String = "workspace.geometry", + preferBonsplitGeometryCallback: Bool = false + ) { + if preferBonsplitGeometryCallback { + deferTerminalGeometryReconcileUntilBonsplitGeometryChange(reason: reason) + return + } + beginEventDrivenLayoutFollowUp( - reason: "workspace.geometry", + reason: reason, includeGeometry: true ) } @@ -9027,16 +8957,29 @@ final class Workspace: Identifiable, ObservableObject { return false } - private func scheduleMovedTerminalRefresh(panelId: UUID) { + private func scheduleMovedTerminalRefresh(panelId: UUID, reason: String = "workspace.move") { guard terminalPanel(for: panelId) != nil else { return } // Force an NSViewRepresentable update after drag/move reparenting. This keeps // portal host binding current when a pane auto-closes during tab moves. terminalPanel(for: panelId)?.requestViewReattach() +#if DEBUG + dlog( + "geometry.moveRefresh.schedule ws=\(id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=\(reason)" + ) +#endif + let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { guard let self, let panel = self.terminalPanel(for: panelId) else { return } +#if DEBUG + let beforeState = self.debugTerminalGeometryState( + for: panel, + visibleInLayout: self.renderedVisiblePanelIdsForCurrentLayout().contains(panel.id) + ) +#endif panel.hostedView.reconcileGeometryNow() if panel.surface.surface != nil { panel.surface.forceRefresh() @@ -9044,6 +8987,17 @@ final class Workspace: Identifiable, ObservableObject { if panel.surface.surface == nil { panel.surface.requestBackgroundSurfaceStartIfNeeded() } +#if DEBUG + let afterState = self.debugTerminalGeometryState( + for: panel, + visibleInLayout: self.renderedVisiblePanelIdsForCurrentLayout().contains(panel.id) + ) + dlog( + "geometry.moveRefresh.pass ws=\(self.id.uuidString.prefix(5)) " + + "panel=\(panelId.uuidString.prefix(5)) reason=\(reason) delayMs=\(String(format: "%.0f", delay * 1000)) " + + "before={\(beforeState)} after={\(afterState)}" + ) +#endif } } @@ -9053,6 +9007,163 @@ final class Workspace: Identifiable, ObservableObject { runRefreshPass(0.03) } + private func geometryNeedsReattach( + isAttached: Bool, + hasUsableBounds: Bool, + hasSurface: Bool + ) -> Bool { + !isAttached || !hasUsableBounds || !hasSurface + } + + private func geometryNeedsRefresh( + visibleInLayout: Bool, + requestedReattach: Bool, + geometryChanged: Bool, + hadSurfaceBeforeReconcile: Bool, + hasSurfaceAfterReconcile: Bool + ) -> Bool { + guard visibleInLayout else { return false } + return requestedReattach || geometryChanged || (!hadSurfaceBeforeReconcile && hasSurfaceAfterReconcile) + } + +#if DEBUG + private func debugShortPanelIds(_ panelIds: Set) -> String { + let ordered = panelIds + .map { String($0.uuidString.prefix(5)) } + .sorted() + return ordered.isEmpty ? "-" : ordered.joined(separator: ",") + } + + private func debugPaneSelectionSummary() -> String { + let paneSummaries = bonsplitController.allPaneIds.map { paneId in + let tabSummaries = bonsplitController.tabs(inPane: paneId).map { tab -> String in + let panelShort = panelIdFromSurfaceId(tab.id).map { String($0.uuidString.prefix(5)) } ?? "none" + let selected = bonsplitController.selectedTab(inPane: paneId)?.id == tab.id ? "*" : "" + return "\(selected)\(panelShort)" + } + let paneMarker = bonsplitController.focusedPaneId == paneId ? "*" : "" + return "\(paneMarker)\(paneId.id.uuidString.prefix(5))[\(tabSummaries.joined(separator: ","))]" + } + return paneSummaries.isEmpty ? "-" : paneSummaries.joined(separator: " ") + } + + private func debugTerminalGeometryState( + for panel: TerminalPanel, + visibleInLayout: Bool + ) -> String { + let hostedView = panel.hostedView + let pixels = panel.surface.debugCurrentPixelSize() + return [ + "visible=\(visibleInLayout ? 1 : 0)", + "active=\(hostedView.debugPortalActive ? 1 : 0)", + "portalVisible=\(hostedView.debugPortalVisibleInUI ? 1 : 0)", + "attached=\((hostedView.window != nil && hostedView.superview != nil) ? 1 : 0)", + "surface=\(panel.surface.surface != nil ? 1 : 0)", + "hidden=\(hostedView.isHidden ? 1 : 0)", + "bounds=\(String(format: "%.1fx%.1f", hostedView.bounds.width, hostedView.bounds.height))", + "frame=\(String(format: "%.1fx%.1f", hostedView.frame.width, hostedView.frame.height))", + "pixels=\(pixels.width)x\(pixels.height)" + ].joined(separator: " ") + } + + private func debugGeometryReattachReasons( + isAttached: Bool, + hasUsableBounds: Bool, + hasSurface: Bool + ) -> [String] { + guard geometryNeedsReattach( + isAttached: isAttached, + hasUsableBounds: hasUsableBounds, + hasSurface: hasSurface + ) else { + return [] + } + + var reasons: [String] = [] + if !isAttached { + reasons.append("detached") + } + if !hasUsableBounds { + reasons.append("bounds") + } + if !hasSurface { + reasons.append("surface") + } + return reasons + } + + private func debugGeometryRefreshReasons( + visibleInLayout: Bool, + requestedReattach: Bool, + geometryChanged: Bool, + hadSurfaceBeforeReconcile: Bool, + hasSurfaceAfterReconcile: Bool + ) -> [String] { + guard geometryNeedsRefresh( + visibleInLayout: visibleInLayout, + requestedReattach: requestedReattach, + geometryChanged: geometryChanged, + hadSurfaceBeforeReconcile: hadSurfaceBeforeReconcile, + hasSurfaceAfterReconcile: hasSurfaceAfterReconcile + ) else { + return [] + } + + var reasons: [String] = [] + if requestedReattach { + reasons.append("reattach") + } + if geometryChanged { + reasons.append("geometry") + } + if !hadSurfaceBeforeReconcile && hasSurfaceAfterReconcile { + reasons.append("surfaceReady") + } + return reasons + } + + private func debugFocusSnapshot(reason: String) -> String { + let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" + let focusedPanelShort = focusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() + + let paneSelections = bonsplitController.allPaneIds.map { paneId in + let selectedTab = bonsplitController.selectedTab(inPane: paneId) + let selectedPanel = selectedTab.flatMap { panelIdFromSurfaceId($0.id) } + let selectedPanelShort = selectedPanel.map { String($0.uuidString.prefix(5)) } ?? "nil" + let marker = bonsplitController.focusedPaneId == paneId ? "*" : "" + return "\(marker)\(paneId.id.uuidString.prefix(5))=\(selectedPanelShort)" + } + + let visibleTerminals = visiblePanelIds + .compactMap { terminalPanel(for: $0) } + .sorted { $0.id.uuidString < $1.id.uuidString } + .map { panel in + let hostedView = panel.hostedView + let firstResponder = hostedView.isSurfaceViewFirstResponder() ? 1 : 0 + return + "\(panel.id.uuidString.prefix(5))" + + "{active=\(hostedView.debugPortalActive ? 1 : 0)" + + ",visible=\(hostedView.debugPortalVisibleInUI ? 1 : 0)" + + ",fr=\(firstResponder)" + + ",hidden=\(hostedView.isHidden ? 1 : 0)" + + ",attached=\((hostedView.window != nil && hostedView.superview != nil) ? 1 : 0)}" + } + + let firstResponderSurface = panels.values + .compactMap { $0 as? TerminalPanel } + .first(where: { $0.hostedView.isSurfaceViewFirstResponder() })? + .id.uuidString.prefix(5) ?? "nil" + + return + "focus.snapshot workspace=\(id.uuidString.prefix(5)) " + + "reason=\(reason) focusedPane=\(focusedPaneShort) focusedPanel=\(focusedPanelShort) " + + "firstResponderSurface=\(firstResponderSurface) " + + "paneSelections=[\(paneSelections.joined(separator: " "))] " + + "visibleTerminals=[\(visibleTerminals.joined(separator: " "))]" + } +#endif + private func closeTabs(_ tabIds: [TabID], skipPinned: Bool = true) { for tabId in tabIds { if skipPinned, @@ -9299,29 +9410,14 @@ extension Workspace: BonsplitDelegate { @MainActor private func confirmClosePanel(for tabId: TabID) async -> Bool { +#if DEBUG + if let handler = AppDelegate.shared?.debugClosePanelConfirmationHandler { + return handler(tabId) + } +#endif let alert = NSAlert() - alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") - - let panelName: String? = { - guard let panelId = panelIdFromSurfaceId(tabId) else { return nil } - if let custom = panelCustomTitles[panelId], !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return custom - } - if let title = panelTitles[panelId], !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return title - } - if let dir = panelDirectories[panelId], !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return (dir as NSString).lastPathComponent - } - return nil - }() - - if let panelName { - alert.informativeText = String(localized: "dialog.closeTab.messageNamed", defaultValue: "This will close \"\(panelName)\".") - } else { - alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") - } + alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") alert.alertStyle = .warning alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel")) @@ -9444,7 +9540,7 @@ extension Workspace: BonsplitDelegate { if debugStressPreloadSelectionDepth > 0 { if let terminalPanel = panel as? TerminalPanel { terminalPanel.requestViewReattach() - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile(reason: "workspace.applyTabSelection.debugStress") terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() } return @@ -9561,6 +9657,7 @@ extension Workspace: BonsplitDelegate { "focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " + "prevPanel=\(prevPanelShort)" ) + dlog(debugFocusSnapshot(reason: "applyTabSelection")) #endif } @@ -9804,7 +9901,7 @@ extension Workspace: BonsplitDelegate { #if DEBUG NSLog("[Workspace] didCloseTab: no panelId for tabId") #endif - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile(reason: "workspace.didCloseTab.missingPanel") if !isDetaching { scheduleFocusReconcile() } @@ -9831,14 +9928,9 @@ extension Workspace: BonsplitDelegate { isLoading: browserPanel?.isLoading ?? false, isPinned: pinnedPanelIds.contains(panelId), directory: panelDirectories[panelId], - ttyName: surfaceTTYNames[panelId], cachedTitle: cachedTitle, customTitle: panelCustomTitles[panelId], - manuallyUnread: manualUnreadPanelIds.contains(panelId), - isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), - remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId) - ? remoteConfiguration?.relayPort - : nil + manuallyUnread: manualUnreadPanelIds.contains(panelId) ) } else { if let closedBrowserRestoreSnapshot { @@ -9875,7 +9967,7 @@ extension Workspace: BonsplitDelegate { // prune the source workspace/window after the tab is attached elsewhere. if panels.isEmpty { if isDetaching { - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile(reason: "workspace.didCloseTab.detachingEmpty") return } @@ -9886,7 +9978,7 @@ extension Workspace: BonsplitDelegate { bonsplitController.selectTab(replacementTabId) applyTabSelection(tabId: replacementTabId, inPane: replacementPane) } - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile(reason: "workspace.didCloseTab.replacement") scheduleFocusReconcile() return } @@ -9909,7 +10001,10 @@ extension Workspace: BonsplitDelegate { if bonsplitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile( + reason: "workspace.didCloseTab", + preferBonsplitGeometryCallback: true + ) if !isDetaching { scheduleFocusReconcile() } @@ -9951,7 +10046,7 @@ extension Workspace: BonsplitDelegate { let movedPanelIdAfter = panelIdFromSurfaceId(tab.id) #endif if let movedPanelId = panelIdFromSurfaceId(tab.id) { - scheduleMovedTerminalRefresh(panelId: movedPanelId) + scheduleMovedTerminalRefresh(panelId: movedPanelId, reason: "workspace.didMoveTab") } #if DEBUG let selectedAfter = controller.selectedTab(inPane: destination) @@ -9967,7 +10062,7 @@ extension Workspace: BonsplitDelegate { #endif normalizePinnedTabs(in: source) normalizePinnedTabs(in: destination) - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile(reason: "workspace.didMoveTab") if !isDetachingCloseTransaction { scheduleFocusReconcile() } @@ -10027,7 +10122,10 @@ extension Workspace: BonsplitDelegate { } } - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile( + reason: "workspace.didClosePane", + preferBonsplitGeometryCallback: true + ) if shouldScheduleFocusReconcile { scheduleFocusReconcile() } @@ -10095,7 +10193,10 @@ extension Workspace: BonsplitDelegate { guard !isProgrammaticSplit else { normalizePinnedTabs(in: originalPane) normalizePinnedTabs(in: newPane) - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile( + reason: "workspace.didSplit.programmatic", + preferBonsplitGeometryCallback: true + ) return } @@ -10138,7 +10239,6 @@ extension Workspace: BonsplitDelegate { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) - configureTerminalPanel(replacementPanel) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) @@ -10177,7 +10277,10 @@ extension Workspace: BonsplitDelegate { } normalizePinnedTabs(in: originalPane) normalizePinnedTabs(in: newPane) - scheduleTerminalGeometryReconcile() + scheduleTerminalGeometryReconcile( + reason: "workspace.didSplit.drag", + preferBonsplitGeometryCallback: true + ) return } @@ -10205,7 +10308,6 @@ extension Workspace: BonsplitDelegate { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) - configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) @@ -10240,7 +10342,10 @@ extension Workspace: BonsplitDelegate { if self.bonsplitController.focusedPaneId == newPane { self.bonsplitController.selectTab(newTabId) } - self.scheduleTerminalGeometryReconcile() + self.scheduleTerminalGeometryReconcile( + reason: "workspace.didSplit.autoCreate", + preferBonsplitGeometryCallback: true + ) self.scheduleFocusReconcile() } } @@ -10300,8 +10405,9 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { - tmuxLayoutSnapshot = snapshot - scheduleTerminalGeometryReconcile() + _ = snapshot + let reason = consumeDeferredBonsplitGeometryReason(defaultReason: "workspace.didChangeGeometry") + scheduleTerminalGeometryReconcile(reason: reason) if !isDetachingCloseTransaction { scheduleFocusReconcile() } From f384bbf681ad35dcf3d10db8979c64df234fc1d4 Mon Sep 17 00:00:00 2001 From: Gale W Date: Sun, 22 Mar 2026 22:43:14 -0400 Subject: [PATCH 2/4] Restore mainline workspace integration hooks --- Sources/AppDelegate.swift | 12 ++- Sources/Workspace.swift | 218 +++++++++++++++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 3 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index db3c22652598..d1eb6ef0f919 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1085,7 +1085,7 @@ final class ServeWebOutputCollector { enum WorkspaceShortcutMapper { /// Maps Cmd+digit workspace shortcuts to a zero-based workspace index. /// Cmd+1...Cmd+8 target fixed indices; Cmd+9 always targets the last workspace. - static func workspaceIndex(forCommandDigit digit: Int, workspaceCount: Int) -> Int? { + static func workspaceIndex(forDigit digit: Int, workspaceCount: Int) -> Int? { guard workspaceCount > 0 else { return nil } guard (1...9).contains(digit) else { return nil } @@ -1097,12 +1097,20 @@ enum WorkspaceShortcutMapper { return index < workspaceCount ? index : nil } + static func workspaceIndex(forCommandDigit digit: Int, workspaceCount: Int) -> Int? { + workspaceIndex(forDigit: digit, workspaceCount: workspaceCount) + } + /// Returns the primary Cmd+digit badge to display for a workspace row. /// Picks the lowest digit that maps to that row index. + static func digitForWorkspace(at index: Int, workspaceCount: Int) -> Int? { + commandDigitForWorkspace(at: index, workspaceCount: workspaceCount) + } + static func commandDigitForWorkspace(at index: Int, workspaceCount: Int) -> Int? { guard index >= 0 && index < workspaceCount else { return nil } for digit in 1...9 { - if workspaceIndex(forCommandDigit: digit, workspaceCount: workspaceCount) == index { + if workspaceIndex(forDigit: digit, workspaceCount: workspaceCount) == index { return digit } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7763a44a62bb..4444eee5f88b 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4691,6 +4691,129 @@ enum SidebarBranchOrdering { let directory: String? } + fileprivate static func normalizedDirectory(_ text: String?) -> String? { + guard let text else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func relativePathFromTilde(_ directory: String) -> String? { + let normalized = normalizedDirectory(directory) + switch normalized { + case "~": + return "" + case let path? where path.hasPrefix("~/"): + return String(path.dropFirst(2)) + default: + return nil + } + } + + private static func commonHomeDirectoryPrefix(from absoluteDirectory: String) -> String? { + guard let normalized = normalizedDirectory(absoluteDirectory) else { return nil } + let standardized = NSString(string: normalized).standardizingPath + if standardized == "/root" || standardized.hasPrefix("/root/") { + return "/root" + } + + let components = NSString(string: standardized).pathComponents + if components.count >= 3, components[0] == "/", components[1] == "Users" { + return NSString.path(withComponents: Array(components.prefix(3))) + } + if components.count >= 3, components[0] == "/", components[1] == "home" { + return NSString.path(withComponents: Array(components.prefix(3))) + } + if components.count >= 4, components[0] == "/", components[1] == "var", components[2] == "home" { + return NSString.path(withComponents: Array(components.prefix(4))) + } + + return nil + } + + private static func inferredHomeDirectory( + matchingTildeDirectory tildeDirectory: String, + absoluteDirectory: String + ) -> String? { + guard let relativePath = relativePathFromTilde(tildeDirectory), + let normalizedAbsolute = normalizedDirectory(absoluteDirectory) else { return nil } + let standardizedAbsolute = NSString(string: normalizedAbsolute).standardizingPath + let homeDirectory: String + if relativePath.isEmpty { + homeDirectory = standardizedAbsolute + } else { + let suffix = "/" + relativePath + guard standardizedAbsolute.hasSuffix(suffix) else { return nil } + homeDirectory = String(standardizedAbsolute.dropLast(suffix.count)) + } + + guard commonHomeDirectoryPrefix(from: homeDirectory) == homeDirectory else { return nil } + return homeDirectory + } + + fileprivate static func inferredRemoteHomeDirectory( + from directories: [String], + fallbackDirectory: String? + ) -> String? { + let candidates = directories + [fallbackDirectory].compactMap { $0 } + let tildeDirectories = candidates.compactMap { directory -> String? in + guard let normalized = normalizedDirectory(directory), + relativePathFromTilde(normalized) != nil else { return nil } + return normalized + } + let absoluteDirectories = candidates.compactMap { directory -> String? in + guard let normalized = normalizedDirectory(directory), normalized.hasPrefix("/") else { return nil } + return NSString(string: normalized).standardizingPath + } + + let inferredHomes = Set( + tildeDirectories.flatMap { tildeDirectory in + absoluteDirectories.compactMap { absoluteDirectory in + inferredHomeDirectory( + matchingTildeDirectory: tildeDirectory, + absoluteDirectory: absoluteDirectory + ) + } + } + ) + + if inferredHomes.count == 1 { + return inferredHomes.first + } + if !inferredHomes.isEmpty { + return nil + } + + return absoluteDirectories.lazy.compactMap(commonHomeDirectoryPrefix(from:)).first + } + + private static func expandedTildePath( + _ directory: String, + homeDirectoryForTildeExpansion: String? + ) -> String { + guard let relativePath = relativePathFromTilde(directory), + let homeDirectory = normalizedDirectory(homeDirectoryForTildeExpansion) else { + return directory + } + if relativePath.isEmpty { + return homeDirectory + } + return NSString(string: homeDirectory).appendingPathComponent(relativePath) + } + + fileprivate static func canonicalDirectoryKey( + _ directory: String?, + homeDirectoryForTildeExpansion: String? + ) -> String? { + guard let directory = normalizedDirectory(directory) else { return nil } + let expanded = expandedTildePath( + directory, + homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + ) + let standardized = NSString(string: expanded).standardizingPath + let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) + return cleaned.isEmpty ? nil : cleaned + } + static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { switch tree { case .pane(let pane): @@ -5027,6 +5150,10 @@ final class Workspace: Identifiable, ObservableObject { @Published private(set) var panelCustomTitles: [UUID: String] = [:] @Published private(set) var pinnedPanelIds: Set = [] @Published private(set) var manualUnreadPanelIds: Set = [] + @Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot? + @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? + @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? + @Published private(set) var tmuxWorkspaceFlashToken: UInt64 = 0 private var manualUnreadMarkedAt: [UUID: Date] = [:] nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 @@ -5263,6 +5390,7 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: initialTerminalCommand, initialEnvironmentOverrides: initialTerminalEnvironment ) + configureTerminalPanel(terminalPanel) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) @@ -5313,6 +5441,7 @@ final class Workspace: Identifiable, ObservableObject { } bonsplitController.selectTab(initialTabId) } + tmuxLayoutSnapshot = bonsplitController.layoutSnapshot() } deinit { @@ -5473,6 +5602,19 @@ final class Workspace: Identifiable, ObservableObject { setPreferredBrowserProfileID(browserPanel.profileID) } + private func configureTerminalPanel(_ terminalPanel: TerminalPanel) { + terminalPanel.onRequestWorkspacePaneFlash = { [weak self, weak terminalPanel] reason in + guard let self, let terminalPanel else { return } + self.triggerWorkspacePaneFlash(panelId: terminalPanel.id, reason: reason) + } + } + + private func triggerWorkspacePaneFlash(panelId: UUID, reason: WorkspaceAttentionFlashReason) { + tmuxWorkspaceFlashPanelId = panelId + tmuxWorkspaceFlashReason = reason + tmuxWorkspaceFlashToken &+= 1 + } + func setPreferredBrowserProfileID(_ profileID: UUID?) { guard let profileID else { preferredBrowserProfileID = nil @@ -6343,6 +6485,75 @@ final class Workspace: Identifiable, ObservableObject { trackRemoteTerminalSurface(initialPanelId) } + private func normalizedSidebarDirectory(_ directory: String?) -> String? { + SidebarBranchOrdering.normalizedDirectory(directory) + } + + private func sidebarHomeDirectoryForCanonicalization( + resolvedPanelDirectories: [UUID: String] + ) -> String? { + if isRemoteWorkspace { + return SidebarBranchOrdering.inferredRemoteHomeDirectory( + from: Array(resolvedPanelDirectories.values), + fallbackDirectory: normalizedSidebarDirectory(currentDirectory) + ) + } + return FileManager.default.homeDirectoryForCurrentUser.path + } + + private func sidebarResolvedDirectory(for panelId: UUID) -> String? { + if let directory = normalizedSidebarDirectory(panelDirectories[panelId]) { + return directory + } + if let requestedDirectory = normalizedSidebarDirectory( + terminalPanel(for: panelId)?.requestedWorkingDirectory + ) { + return requestedDirectory + } + guard panelId == focusedPanelId else { return nil } + return normalizedSidebarDirectory(currentDirectory) + } + + private func sidebarResolvedPanelDirectories(orderedPanelIds: [UUID]) -> [UUID: String] { + var resolved: [UUID: String] = [:] + for panelId in orderedPanelIds { + if let directory = sidebarResolvedDirectory(for: panelId) { + resolved[panelId] = directory + } + } + return resolved + } + + func sidebarDirectoriesInDisplayOrder(orderedPanelIds: [UUID]) -> [String] { + let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) + let homeDirectoryForCanonicalization = sidebarHomeDirectoryForCanonicalization( + resolvedPanelDirectories: resolvedDirectories + ) + var ordered: [String] = [] + var seen: Set = [] + + for panelId in orderedPanelIds { + guard let directory = resolvedDirectories[panelId], + let key = SidebarBranchOrdering.canonicalDirectoryKey( + directory, + homeDirectoryForTildeExpansion: homeDirectoryForCanonicalization + ) else { continue } + if seen.insert(key).inserted { + ordered.append(directory) + } + } + + if ordered.isEmpty, let fallbackDirectory = normalizedSidebarDirectory(currentDirectory) { + return [fallbackDirectory] + } + + return ordered + } + + func sidebarDirectoriesInDisplayOrder() -> [String] { + sidebarDirectoriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) + } + private func trackRemoteTerminalSurface(_ panelId: UUID) { guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count @@ -6738,6 +6949,7 @@ final class Workspace: Identifiable, ObservableObject { portOrdinal: portOrdinal, initialCommand: remoteTerminalStartupCommand ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle if remoteTerminalStartupCommand != nil { @@ -6827,6 +7039,7 @@ final class Workspace: Identifiable, ObservableObject { initialCommand: remoteTerminalStartupCommand, additionalEnvironment: startupEnvironment ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle if remoteTerminalStartupCommand != nil { @@ -8115,6 +8328,7 @@ final class Workspace: Identifiable, ObservableObject { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) @@ -10239,6 +10453,7 @@ extension Workspace: BonsplitDelegate { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) + configureTerminalPanel(replacementPanel) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) @@ -10308,6 +10523,7 @@ extension Workspace: BonsplitDelegate { configTemplate: inheritedConfig, portOrdinal: portOrdinal ) + configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) @@ -10405,7 +10621,7 @@ extension Workspace: BonsplitDelegate { } func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { - _ = snapshot + tmuxLayoutSnapshot = snapshot let reason = consumeDeferredBonsplitGeometryReason(defaultReason: "workspace.didChangeGeometry") scheduleTerminalGeometryReconcile(reason: reason) if !isDetachingCloseTransaction { From c36650759a75a3fef3044f4cb973e08b425a0a77 Mon Sep 17 00:00:00 2001 From: Gale W Date: Sun, 22 Mar 2026 23:16:25 -0400 Subject: [PATCH 3/4] Restore mainline shortcut and remote transfer fixes --- Sources/AppDelegate.swift | 88 ++++++++++++++++++++++++++++++++------- Sources/Workspace.swift | 73 +++++++++++++++++++++++++++----- 2 files changed, 135 insertions(+), 26 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d1eb6ef0f919..24db9e5f5ae6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9437,30 +9437,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } - // Numeric shortcuts for specific sidebar tabs: Cmd+1-9 (9 = last workspace) - if flags == [.command], - let manager = tabManager, - let num = Int(chars), - let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: num, workspaceCount: manager.tabs.count) { + // Numeric shortcuts for specific workspaces (9 = last workspace) + if let manager = tabManager, + let digit = numberedShortcutDigit( + event: event, + shortcut: KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) + ), + let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forDigit: digit, workspaceCount: manager.tabs.count) { #if DEBUG dlog( - "shortcut.action name=workspaceDigit digit=\(num) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + "shortcut.action name=workspaceDigit digit=\(digit) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" ) #endif manager.selectTab(at: targetIndex) return true } - // Numeric shortcuts for surfaces within pane: Ctrl+1-9 (9 = last) - if flags == [.control] { - if let num = Int(chars), num >= 1 && num <= 9 { - if num == 9 { - tabManager?.selectLastSurface() - } else { - tabManager?.selectSurface(at: num - 1) - } - return true + // Numeric shortcuts for surfaces within the focused pane (9 = last) + if let digit = numberedShortcutDigit( + event: event, + shortcut: KeyboardShortcutSettings.shortcut(for: .selectSurfaceByNumber) + ) { + if digit == 9 { + tabManager?.selectLastSurface() + } else { + tabManager?.selectSurface(at: digit - 1) } + return true } // Pane focus navigation (defaults to Cmd+Option+Arrow, but can be customized to letter/number keys). @@ -10486,6 +10489,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return CharacterSet.letters.contains(scalar) } + private func numberedShortcutDigit(event: NSEvent, shortcut: StoredShortcut) -> Int? { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function, .capsLock]) + guard flags == shortcut.modifierFlags else { return nil } + + if let digit = numberedShortcutDigit( + eventCharacter: event.charactersIgnoringModifiers, + applyShiftSymbolNormalization: flags.contains(.shift), + eventKeyCode: event.keyCode + ) { + return digit + } + + let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags) + if let digit = numberedShortcutDigit( + eventCharacter: layoutCharacter, + applyShiftSymbolNormalization: false, + eventKeyCode: event.keyCode + ) { + return digit + } + + return digitForNumberKeyCode(event.keyCode) + } + + private func numberedShortcutDigit( + eventCharacter: String?, + applyShiftSymbolNormalization: Bool, + eventKeyCode: UInt16 + ) -> Int? { + guard let eventCharacter, !eventCharacter.isEmpty else { return nil } + let normalized = normalizedShortcutEventCharacter( + eventCharacter, + applyShiftSymbolNormalization: applyShiftSymbolNormalization, + eventKeyCode: eventKeyCode + ) + guard let digit = Int(normalized), (1...9).contains(digit) else { return nil } + return digit + } + private func shortcutCharacterMatches( eventCharacter: String?, shortcutKey: String, @@ -10598,6 +10641,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func digitForNumberKeyCode(_ keyCode: UInt16) -> Int? { + switch keyCode { + case 18: return 1 // kVK_ANSI_1 + case 19: return 2 // kVK_ANSI_2 + case 20: return 3 // kVK_ANSI_3 + case 21: return 4 // kVK_ANSI_4 + case 23: return 5 // kVK_ANSI_5 + case 22: return 6 // kVK_ANSI_6 + case 26: return 7 // kVK_ANSI_7 + case 28: return 8 // kVK_ANSI_8 + case 25: return 9 // kVK_ANSI_9 + default: return nil + } + } + /// Match arrow key shortcuts using keyCode /// Arrow keys include .numericPad and .function in their modifierFlags, so strip those before comparing. private func matchArrowShortcut(event: NSEvent, shortcut: StoredShortcut, keyCode: UInt16) -> Bool { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 4444eee5f88b..953b35ffa8fc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3514,12 +3514,17 @@ final class WorkspaceRemoteSessionController { ) } - private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult { + private func scpExec( + arguments: [String], + timeout: TimeInterval = 30, + operation: TerminalImageTransferOperation? = nil + ) throws -> CommandResult { try runProcess( executable: "/usr/bin/scp", arguments: arguments, stdin: nil, - timeout: timeout + timeout: timeout, + operation: operation ) } @@ -3529,7 +3534,8 @@ final class WorkspaceRemoteSessionController { environment: [String: String]? = nil, currentDirectory: URL? = nil, stdin: Data?, - timeout: TimeInterval + timeout: TimeInterval, + operation: TerminalImageTransferOperation? = nil ) throws -> CommandResult { debugLog( "remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + @@ -3584,6 +3590,7 @@ final class WorkspaceRemoteSessionController { } do { + try operation?.throwIfCancelled() try process.run() } catch { try? stdoutPipe.fileHandleForWriting.close() @@ -3598,20 +3605,34 @@ final class WorkspaceRemoteSessionController { } try? stdoutPipe.fileHandleForWriting.close() try? stderrPipe.fileHandleForWriting.close() + operation?.installCancellationHandler { + if process.isRunning { + process.terminate() + } + } + defer { operation?.clearCancellationHandler() } if let stdin, let pipe = process.standardInput as? Pipe { pipe.fileHandleForWriting.write(stdin) try? pipe.fileHandleForWriting.close() } - let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success - if !didExitBeforeTimeout, process.isRunning { + func terminateProcessAndWait() { process.terminate() let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success if !terminatedGracefully, process.isRunning { _ = Darwin.kill(process.processIdentifier, SIGKILL) process.waitUntilExit() } + } + + let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success + if !didExitBeforeTimeout, process.isRunning { + if operation?.isCancelled == true { + terminateProcessAndWait() + throw TerminalImageTransferExecutionError.cancelled + } + terminateProcessAndWait() debugLog( "remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" @@ -3626,6 +3647,9 @@ final class WorkspaceRemoteSessionController { try? stderrHandle.close() let stdout = String(data: stdoutData, encoding: .utf8) ?? "" let stderr = String(data: stderrData, encoding: .utf8) ?? "" + if operation?.isCancelled == true { + throw TerminalImageTransferExecutionError.cancelled + } debugLog( "remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + "status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " + @@ -3861,7 +3885,7 @@ final class WorkspaceRemoteSessionController { } scpArgs += [normalizedLocalURL.path, "\(configuration.destination):\(remotePath)"] - let scpResult = try scpExec(arguments: scpArgs, timeout: 45) + let scpResult = try scpExec(arguments: scpArgs, timeout: 45, operation: operation) guard scpResult.status == 0 else { let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)" @@ -4958,6 +4982,7 @@ enum SidebarBranchOrdering { panelBranches: [UUID: SidebarGitBranchState], panelDirectories: [UUID: String], defaultDirectory: String?, + homeDirectoryForTildeExpansion: String?, fallbackBranch: SidebarGitBranchState? ) -> [BranchDirectoryEntry] { struct EntryKey: Hashable { @@ -4979,7 +5004,10 @@ enum SidebarBranchOrdering { func canonicalDirectoryKey(_ directory: String?) -> String? { guard let directory = normalized(directory) else { return nil } - let expanded = NSString(string: directory).expandingTildeInPath + let expanded = SidebarBranchOrdering.expandedTildePath( + directory, + homeDirectoryForTildeExpansion: homeDirectoryForTildeExpansion + ) let standardized = NSString(string: expanded).standardizingPath let cleaned = standardized.trimmingCharacters(in: .whitespacesAndNewlines) return cleaned.isEmpty ? nil : cleaned @@ -5537,9 +5565,12 @@ final class Workspace: Identifiable, ObservableObject { let isLoading: Bool let isPinned: Bool let directory: String? + let ttyName: String? let cachedTitle: String? let customTitle: String? let manuallyUnread: Bool + let isRemoteTerminal: Bool + let remoteRelayPort: Int? } private var detachingTabIds: Set = [] @@ -6234,11 +6265,15 @@ final class Workspace: Identifiable, ObservableObject { func sidebarBranchDirectoryEntriesInDisplayOrder( orderedPanelIds: [UUID] ) -> [SidebarBranchOrdering.BranchDirectoryEntry] { - SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( + let resolvedDirectories = sidebarResolvedPanelDirectories(orderedPanelIds: orderedPanelIds) + return SidebarBranchOrdering.orderedUniqueBranchDirectoryEntries( orderedPanelIds: orderedPanelIds, panelBranches: panelGitBranches, - panelDirectories: panelDirectories, - defaultDirectory: currentDirectory, + panelDirectories: resolvedDirectories, + defaultDirectory: normalizedSidebarDirectory(currentDirectory), + homeDirectoryForTildeExpansion: sidebarHomeDirectoryForCanonicalization( + resolvedPanelDirectories: resolvedDirectories + ), fallbackBranch: gitBranch ) } @@ -7847,6 +7882,11 @@ final class Workspace: Identifiable, ObservableObject { if let directory = detached.directory { panelDirectories[detached.panelId] = directory } + if let ttyName = detached.ttyName?.trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty { + surfaceTTYNames[detached.panelId] = ttyName + } else { + surfaceTTYNames.removeValue(forKey: detached.panelId) + } if let cachedTitle = detached.cachedTitle { panelTitles[detached.panelId] = cachedTitle } @@ -7879,6 +7919,7 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: detached.panelId) panelDirectories.removeValue(forKey: detached.panelId) + surfaceTTYNames.removeValue(forKey: detached.panelId) panelTitles.removeValue(forKey: detached.panelId) panelCustomTitles.removeValue(forKey: detached.panelId) pinnedPanelIds.remove(detached.panelId) @@ -7895,6 +7936,11 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = detached.panelId + if detached.isRemoteTerminal, + let detachedRelayPort = detached.remoteRelayPort, + detachedRelayPort == remoteConfiguration?.relayPort { + trackRemoteTerminalSurface(detached.panelId) + } if let index { _ = bonsplitController.reorderTab(newTabId, toIndex: index) } @@ -10142,9 +10188,14 @@ extension Workspace: BonsplitDelegate { isLoading: browserPanel?.isLoading ?? false, isPinned: pinnedPanelIds.contains(panelId), directory: panelDirectories[panelId], + ttyName: surfaceTTYNames[panelId], cachedTitle: cachedTitle, customTitle: panelCustomTitles[panelId], - manuallyUnread: manualUnreadPanelIds.contains(panelId) + manuallyUnread: manualUnreadPanelIds.contains(panelId), + isRemoteTerminal: activeRemoteTerminalSurfaceIds.contains(panelId), + remoteRelayPort: activeRemoteTerminalSurfaceIds.contains(panelId) + ? remoteConfiguration?.relayPort + : nil ) } else { if let closedBrowserRestoreSnapshot { From 62674f1e9913e6b43a8b88d608e347ee21204742 Mon Sep 17 00:00:00 2001 From: Gale W Date: Sun, 22 Mar 2026 23:28:46 -0400 Subject: [PATCH 4/4] Fix auxiliary shortcut routing and portal sync edges --- Sources/AppDelegate.swift | 31 +++++++++++++++---- Sources/TerminalWindowPortal.swift | 48 +++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 24db9e5f5ae6..807b74345272 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9157,11 +9157,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let hasEventWindowContext = shortcutEventHasAddressableWindow(event) let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event) if hasEventWindowContext && !didSynchronizeShortcutContext { - // AppKit allows auxiliary windows to become key without becoming the app's - // main terminal window. Those windows still need to receive their own close - // shortcut handling instead of being rejected as an unresolved main-window route. - if let eventTargetWindow, !isMainTerminalWindow(eventTargetWindow) { - // Continue into the shortcut-specific auxiliary-window paths below. + if shouldAllowAuxiliaryWindowShortcutWithoutMainRoute( + event: event, + eventTargetWindow: eventTargetWindow + ) { + // Auxiliary windows still need to close themselves even when AppKit never + // resolves them to a main-window routing context. } else { #if DEBUG dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling") @@ -10514,6 +10515,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return digitForNumberKeyCode(event.keyCode) } + private func shouldAllowAuxiliaryWindowShortcutWithoutMainRoute( + event: NSEvent, + eventTargetWindow: NSWindow? + ) -> Bool { + guard let eventTargetWindow, !isMainTerminalWindow(eventTargetWindow) else { + return false + } + return isAuxiliaryWindowCloseShortcut(event: event) + } + + private func isAuxiliaryWindowCloseShortcut(event: NSEvent) -> Bool { + if matchShortcut( + event: event, + shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) + ) { + return true + } + return matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .closeWindow)) + } + private func numberedShortcutDigit( eventCharacter: String?, applyShiftSymbolNormalization: Bool, diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 53178a733d59..64b25f819bff 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -595,6 +595,7 @@ final class WindowTerminalPortal: NSObject { private var deferredFullSyncQueuedCoverage: FullSyncCoverage? private var deferredFullSyncQueuedForce = false private var externalGeometryQueuedCoverage: FullSyncCoverage? + private var externalGeometryQueuedRequiresSettledLayout = false private var lastSatisfiedFullSyncCoverage: FullSyncCoverage? private var syncCoalescingEpoch: UInt64 = 0 private var geometryObservers: [NSObjectProtocol] = [] @@ -800,7 +801,11 @@ final class WindowTerminalPortal: NSObject { fileprivate func scheduleExternalGeometrySynchronize() { let coverage = currentFullSyncCoverage() + let isDragEvent = TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive + let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true || isDragEvent) if hasSatisfiedFullSync(for: coverage) { + externalGeometryQueuedRequiresSettledLayout = + externalGeometryQueuedRequiresSettledLayout || requiresSettledLayout #if DEBUG dlog( "portal.geometry.schedule.skip host=\(portalDebugToken(hostView)) " + @@ -810,6 +815,8 @@ final class WindowTerminalPortal: NSObject { return } if hasQueuedExternalGeometrySync(for: coverage) { + externalGeometryQueuedRequiresSettledLayout = + externalGeometryQueuedRequiresSettledLayout || requiresSettledLayout #if DEBUG dlog( "portal.geometry.schedule.skip host=\(portalDebugToken(hostView)) " + @@ -820,6 +827,8 @@ final class WindowTerminalPortal: NSObject { } if hasExternalGeometrySyncScheduled { externalGeometryQueuedCoverage = coverage + externalGeometryQueuedRequiresSettledLayout = + externalGeometryQueuedRequiresSettledLayout || requiresSettledLayout #if DEBUG dlog( "portal.geometry.schedule.skip host=\(portalDebugToken(hostView)) " + @@ -830,8 +839,7 @@ final class WindowTerminalPortal: NSObject { } hasExternalGeometrySyncScheduled = true externalGeometryQueuedCoverage = coverage - let isDragEvent = TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive - let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true || isDragEvent) + externalGeometryQueuedRequiresSettledLayout = requiresSettledLayout #if DEBUG dlog( "portal.geometry.schedule host=\(portalDebugToken(hostView)) " + @@ -843,10 +851,12 @@ final class WindowTerminalPortal: NSObject { #endif DispatchQueue.main.async { [weak self] in guard let self else { return } + let requestedRequiresSettledLayout = self.externalGeometryQueuedRequiresSettledLayout let performSync = { let requestedCoverage = self.externalGeometryQueuedCoverage ?? coverage self.hasExternalGeometrySyncScheduled = false self.externalGeometryQueuedCoverage = nil + self.externalGeometryQueuedRequiresSettledLayout = false if self.hasSatisfiedFullSync(for: requestedCoverage) { #if DEBUG dlog( @@ -888,7 +898,7 @@ final class WindowTerminalPortal: NSObject { self.markFullSyncSatisfied() } } - if requiresSettledLayout { + if requestedRequiresSettledLayout { DispatchQueue.main.async(execute: performSync) } else { performSync() @@ -1369,14 +1379,13 @@ final class WindowTerminalPortal: NSObject { pruneDeadEntries() let anchorId = ObjectIdentifier(anchorView) let primaryHostedId = hostedByAnchorId[anchorId] - if let primaryHostedId { - _ = synchronizeHostedView(withId: primaryHostedId) - } + let primaryResult = fullSyncPassResultForHostedView(withId: primaryHostedId) // Failsafe: during aggressive divider drags/structural churn, one anchor can miss a // geometry callback while another fires. Reconcile all mapped hosted views so no stale // frame remains "stuck" onscreen until the next interaction. - let syncResult = synchronizeAllHostedViews(excluding: primaryHostedId) + let siblingResult = synchronizeAllHostedViews(excluding: primaryHostedId) + let syncResult = combineFullSyncPassResults(primaryResult, siblingResult) #if DEBUG if syncResult == .transient { AppDelegate.shared?.recordUITestTerminalGeometryEvent( @@ -1404,6 +1413,31 @@ final class WindowTerminalPortal: NSObject { scheduleDeferredFullSynchronizeAll() } + private func fullSyncPassResultForHostedView(withId hostedId: ObjectIdentifier?) -> FullSyncPassResult { + guard let hostedId else { return .vacuous } + switch synchronizeHostedView(withId: hostedId) { + case .settled: + return .settled + case .transient: + return .transient + case .skipped: + return .vacuous + } + } + + private func combineFullSyncPassResults( + _ lhs: FullSyncPassResult, + _ rhs: FullSyncPassResult + ) -> FullSyncPassResult { + if lhs == .transient || rhs == .transient { + return .transient + } + if lhs == .settled || rhs == .settled { + return .settled + } + return .vacuous + } + private func scheduleDeferredFullSynchronizeAll(force: Bool = false) { let coverage = currentFullSyncCoverage() if !force, hasSatisfiedFullSync(for: coverage) {