From d9021861e383b1a5cba3968327fde4e387181b58 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:45:20 -0700 Subject: [PATCH] Preserve ssh remote browser proxy flow --- Sources/GhosttyTerminalView.swift | 74 +- Sources/Panels/BrowserPanel.swift | 116 +- Sources/Panels/TerminalPanelView.swift | 2 +- Sources/SocketControlSettings.swift | 46 + Sources/TabManager.swift | 708 ++----- Sources/TerminalController.swift | 59 +- Sources/WindowToolbarController.swift | 2 +- Sources/Workspace.swift | 2713 ++++++------------------ 8 files changed, 1113 insertions(+), 2607 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 26168b24..04a8aebd 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5326,13 +5326,14 @@ final class GhosttySurfaceScrollView: NSView { private var searchOverlayHostingView: NSHostingView? private var lastSearchOverlayStateID: ObjectIdentifier? private var observers: [NSObjectProtocol] = [] - private var windowObservers: [NSObjectProtocol] = [] - private var isLiveScrolling = false + private var windowObservers: [NSObjectProtocol] = [] + private var isLiveScrolling = false private var lastSentRow: Int? private var isActive = true private var activeDropZone: DropZone? private var pendingDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 + private var pendingAutomaticFirstResponderApply = false // Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection. /// Tracks whether keyboard focus should go to the search field or the terminal @@ -5859,7 +5860,7 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))") #endif - self.applyFirstResponderIfNeeded() + self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey") }) windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, @@ -5882,7 +5883,9 @@ final class GhosttySurfaceScrollView: NSView { #endif } }) - if window.isKeyWindow { applyFirstResponderIfNeeded() } + if window.isKeyWindow { + scheduleAutomaticFirstResponderApply(reason: "viewDidMoveToWindow") + } } func attachSurface(_ terminalSurface: TerminalSurface) { @@ -6279,7 +6282,7 @@ final class GhosttySurfaceScrollView: NSView { window.makeFirstResponder(nil) } } else { - applyFirstResponderIfNeeded() + scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI") } } @@ -6297,7 +6300,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif if active { - applyFirstResponderIfNeeded() + scheduleAutomaticFirstResponderApply(reason: "setActive") } else if let window, let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { @@ -6579,6 +6582,20 @@ final class GhosttySurfaceScrollView: NSView { return fr === surfaceView || fr.isDescendant(of: surfaceView) } + private func scheduleAutomaticFirstResponderApply(reason: String) { + guard !pendingAutomaticFirstResponderApply else { return } + pendingAutomaticFirstResponderApply = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.pendingAutomaticFirstResponderApply = false +#if DEBUG + let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + dlog("find.applyFirstResponder.defer surface=\(surfaceShort) reason=\(reason)") +#endif + self.applyFirstResponderIfNeeded() + } + } + private func applyFirstResponderIfNeeded() { let hasUsablePortalGeometry: Bool = { let size = bounds.size @@ -6948,35 +6965,15 @@ final class GhosttySurfaceScrollView: NSView { /// regions such as scrollbar space) when telling libghostty the terminal size. @discardableResult private func synchronizeCoreSurface() -> Bool { - let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth()) + // Reserving extra overlay-scroller gutter here causes AppKit and libghostty to fight + // over terminal columns during split churn. The width can flap by one scrollbar gutter, + // which redraws the shell prompt multiple times on Cmd+D. Favor stable columns. + let width = max(0, scrollView.contentSize.width) let height = surfaceView.frame.height guard width > 0, height > 0 else { return false } return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) } - /// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller. - private func overlayScrollbarInsetWidth() -> CGFloat { - guard scrollView.hasVerticalScroller, scrollView.scrollerStyle == .overlay else { return 0 } - - // If AppKit already reserved non-content width in `contentSize`, avoid double-subtraction. - let alreadyReserved = max(0, scrollView.bounds.width - scrollView.contentSize.width) - if alreadyReserved > 0.5 { return 0 } - - let fallback = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay) - guard let verticalScroller = scrollView.verticalScroller else { return fallback } - - let measuredWidth = verticalScroller.frame.width - if measuredWidth > 0 { - return max(measuredWidth, fallback) - } - - let controlSizeWidth = NSScroller.scrollerWidth( - for: verticalScroller.controlSize, - scrollerStyle: .overlay - ) - return max(controlSizeWidth, fallback) - } - private func updateNotificationRingPath() { updateOverlayRingPath( layer: notificationRingLayer, @@ -7431,6 +7428,12 @@ struct GhosttyTerminalView: NSViewRepresentable { } let portalExpectedSurfaceId = terminalSurface.id let portalExpectedGeneration = terminalSurface.portalBindingGeneration() + func portalBindingStillLive() -> Bool { + terminalSurface.canAcceptPortalBinding( + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration + ) + } let forwardedDropZone = isVisibleInUI ? paneDropZone : nil #if DEBUG if coordinator.lastPaneDropZone != paneDropZone { @@ -7469,6 +7472,7 @@ struct GhosttyTerminalView: NSViewRepresentable { reason: "didMoveToWindow" ) else { return } guard host.window != nil else { return } + guard portalBindingStillLive() else { return } TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, @@ -7492,6 +7496,7 @@ struct GhosttyTerminalView: NSViewRepresentable { bounds: host.bounds, reason: "geometryChanged" ) else { return } + guard portalBindingStillLive() else { return } let hostId = ObjectIdentifier(host) if host.window != nil, (coordinator.lastBoundHostId != hostId || @@ -7521,6 +7526,7 @@ struct GhosttyTerminalView: NSViewRepresentable { } if host.window != nil, hostOwnsPortalNow { + let portalBindingLive = portalBindingStillLive() let hostId = ObjectIdentifier(host) let geometryRevision = host.geometryRevision let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) @@ -7531,7 +7537,7 @@ struct GhosttyTerminalView: NSViewRepresentable { previousDesiredIsVisibleInUI != isVisibleInUI || previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || previousDesiredPortalZPriority != portalZPriority - if shouldBindNow { + if portalBindingLive && shouldBindNow { #if DEBUG if portalEntryMissing { dlog( @@ -7551,11 +7557,11 @@ struct GhosttyTerminalView: NSViewRepresentable { ) coordinator.lastBoundHostId = hostId coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + } else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { TerminalWindowPortalRegistry.synchronizeForAnchor(host) coordinator.lastSynchronizedHostGeometryRevision = geometryRevision } - } else if hostOwnsPortalNow { + } else if hostOwnsPortalNow, portalBindingStillLive() { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync // that runs before the deferred bind completes won't hide the view. @@ -7585,7 +7591,7 @@ struct GhosttyTerminalView: NSViewRepresentable { isBoundToCurrentHost: isBoundToCurrentHost ) - if shouldApplyImmediateHostedState { + if portalBindingStillLive() && shouldApplyImmediateHostedState { hostedView.setVisibleInUI(isVisibleInUI) hostedView.setActive(isActive) } else { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index aaad9f23..b4153594 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3,6 +3,19 @@ import Combine import WebKit import AppKit import Bonsplit +import Network + +struct BrowserProxyEndpoint: Equatable { + let host: String + let port: Int +} + +struct BrowserRemoteWorkspaceStatus: Equatable { + let target: String + let connectionState: WorkspaceRemoteConnectionState + let heartbeatCount: Int + let lastHeartbeatAt: Date? +} enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { @@ -1255,6 +1268,14 @@ final class BrowserPortalAnchorView: NSView { @MainActor final class BrowserPanel: Panel, ObservableObject { + private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" + private static let remoteLoopbackHosts: Set = [ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + ] + /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() @@ -1750,6 +1771,8 @@ final class BrowserPanel: Panel, ObservableObject { private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 private let developerToolsRestoreRetryMaxAttempts: Int = 40 + private var remoteProxyEndpoint: BrowserProxyEndpoint? + @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? private var browserThemeMode: BrowserThemeMode var displayTitle: String { @@ -1917,15 +1940,24 @@ final class BrowserPanel: Panel, ObservableObject { setupObservers(for: webView) } - init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { + init( + workspaceId: UUID, + initialURL: URL? = nil, + bypassInsecureHTTPHostOnce: String? = nil, + proxyEndpoint: BrowserProxyEndpoint? = nil, + isRemoteWorkspace: Bool = false + ) { self.id = UUID() self.workspaceId = workspaceId self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") + self.remoteProxyEndpoint = proxyEndpoint self.browserThemeMode = BrowserThemeSettings.mode() let webView = Self.makeWebView() self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } + let _ = isRemoteWorkspace + applyRemoteProxyConfigurationIfAvailable() // Set up navigation delegate let navDelegate = BrowserNavigationDelegate() @@ -2003,6 +2035,40 @@ final class BrowserPanel: Panel, ObservableObject { } } + func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { + guard remoteProxyEndpoint != endpoint else { return } + remoteProxyEndpoint = endpoint + applyRemoteProxyConfigurationIfAvailable() + } + + func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { + guard remoteWorkspaceStatus != status else { return } + remoteWorkspaceStatus = status + } + + private func applyRemoteProxyConfigurationIfAvailable() { + guard #available(macOS 14.0, *) else { return } + + let store = webView.configuration.websiteDataStore + guard let endpoint = remoteProxyEndpoint else { + store.proxyConfigurations = [] + return + } + + let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, + endpoint.port > 0 && endpoint.port <= 65535, + let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else { + store.proxyConfigurations = [] + return + } + + let nwEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort) + let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint) + let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint) + store.proxyConfigurations = [socks, connect] + } + private func beginDownloadActivity() { let apply = { self.activeDownloadCount += 1 @@ -2481,6 +2547,7 @@ final class BrowserPanel: Panel, ObservableObject { if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } + let effectiveRequest = remoteProxyPreparedNavigationRequest(from: request) // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true @@ -2488,7 +2555,35 @@ final class BrowserPanel: Panel, ObservableObject { BrowserHistoryStore.shared.recordTypedNavigation(url: url) } navigationDelegate?.lastAttemptedURL = url - browserLoadRequest(request, in: webView) + browserLoadRequest(effectiveRequest, in: webView) + } + + private func remoteProxyPreparedNavigationRequest(from request: URLRequest) -> URLRequest { + guard remoteProxyEndpoint != nil else { return request } + guard let url = request.url else { return request } + guard let rewrittenURL = Self.remoteProxyLoopbackAliasURL(for: url) else { return request } + + var rewrittenRequest = request + rewrittenRequest.url = rewrittenURL +#if DEBUG + dlog( + "browser.remoteProxy.rewrite " + + "panel=\(id.uuidString.prefix(5)) " + + "from=\(url.absoluteString) " + + "to=\(rewrittenURL.absoluteString)" + ) +#endif + return rewrittenRequest + } + + private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { + guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil } + guard remoteLoopbackHosts.contains(host) else { return nil } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.host = remoteLoopbackProxyAliasHost + return components?.url } /// Navigate with smart URL/search detection @@ -2956,6 +3051,16 @@ extension BrowserPanel { applyPageZoom(1.0) } + func currentPageZoomFactor() -> CGFloat { + webView.pageZoom + } + + @discardableResult + func setPageZoomFactor(_ pageZoom: CGFloat) -> Bool { + let clamped = max(minPageZoom, min(maxPageZoom, pageZoom)) + return applyPageZoom(clamped) + } + /// Take a snapshot of the web view func takeSnapshot(completion: @escaping (NSImage?) -> Void) { let config = WKSnapshotConfiguration() @@ -3564,6 +3669,13 @@ extension BrowserPanel { return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)" } + func hideBrowserPortalView(source: String) { + BrowserWindowPortalRegistry.hide( + webView: webView, + source: source + ) + } + } #endif diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index a98c5338..200104df 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -24,9 +24,9 @@ struct TerminalPanelView: View { portalZPriority: portalPriority, showsInactiveOverlay: isSplit && !isFocused, showsUnreadNotificationRing: hasUnreadNotification, - searchState: panel.searchState, inactiveOverlayColor: appearance.unfocusedOverlayNSColor, inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, + searchState: panel.searchState, reattachToken: panel.viewReattachToken, onFocus: { _ in onFocus() }, onTriggerFlash: onTriggerFlash diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 6a12a955..efe8cfa8 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -406,6 +406,18 @@ struct SocketControlSettings { ) -> String { let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild) + if let taggedDebugPath = taggedDebugSocketPath( + bundleIdentifier: bundleIdentifier, + environment: environment + ) { + if isTruthy(environment[allowSocketPathOverrideKey]), + let override = environment["CMUX_SOCKET_PATH"], + !override.isEmpty { + return override + } + return taggedDebugPath + } + guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else { return fallback } @@ -422,6 +434,9 @@ struct SocketControlSettings { } static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String { + if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) { + return taggedDebugPath + } if bundleIdentifier == "com.cmuxterm.app.nightly" { return "/tmp/cmux-nightly.sock" } @@ -454,6 +469,37 @@ struct SocketControlSettings { || bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") } + static func taggedDebugSocketPath( + bundleIdentifier: String?, + environment: [String: String] + ) -> String? { + let bundleId = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if bundleId.hasPrefix("\(baseDebugBundleIdentifier).") { + let suffix = String(bundleId.dropFirst(baseDebugBundleIdentifier.count + 1)) + let slug = suffix + .replacingOccurrences(of: ".", with: "-") + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + if !slug.isEmpty { + return "/tmp/cmux-debug-\(slug).sock" + } + } + + let tag = launchTag(environment: environment)? + .lowercased() + .replacingOccurrences(of: ".", with: "-") + .replacingOccurrences(of: "_", with: "-") + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: "-") + + guard bundleId == baseDebugBundleIdentifier, + let tag, + !tag.isEmpty else { + return nil + } + return "/tmp/cmux-debug-\(tag).sock" + } + static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } return bundleIdentifier == "com.cmuxterm.app.staging" diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index c17d8b63..eaf1a202 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -19,22 +19,22 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable { var displayName: String { switch self { case .top: - return String(localized: "workspace.placement.top", defaultValue: "Top") + return "Top" case .afterCurrent: - return String(localized: "workspace.placement.afterCurrent", defaultValue: "After current") + return "After current" case .end: - return String(localized: "workspace.placement.end", defaultValue: "End") + return "End" } } var description: String { switch self { case .top: - return String(localized: "workspace.placement.top.description", defaultValue: "Insert new workspaces at the top of the list.") + return "Insert new workspaces at the top of the list." case .afterCurrent: - return String(localized: "workspace.placement.afterCurrent.description", defaultValue: "Insert new workspaces directly after the active workspace.") + return "Insert new workspaces directly after the active workspace." case .end: - return String(localized: "workspace.placement.end.description", defaultValue: "Append new workspaces to the bottom of the list.") + return "Append new workspaces to the bottom of the list." } } } @@ -72,9 +72,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { var displayName: String { switch self { case .leftRail: - return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail") + return "Left Rail" case .solidFill: - return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill") + return "Solid Fill" } } } @@ -105,6 +105,47 @@ enum SidebarActiveTabIndicatorSettings { } } +enum WorkspacePlacementSettings { + static let placementKey = "newWorkspacePlacement" + static let defaultPlacement: NewWorkspacePlacement = .afterCurrent + + static func current(defaults: UserDefaults = .standard) -> NewWorkspacePlacement { + guard let raw = defaults.string(forKey: placementKey), + let placement = NewWorkspacePlacement(rawValue: raw) else { + return defaultPlacement + } + return placement + } + + static func insertionIndex( + placement: NewWorkspacePlacement, + selectedIndex: Int?, + selectedIsPinned: Bool, + pinnedCount: Int, + totalCount: Int + ) -> Int { + let clampedTotalCount = max(0, totalCount) + let clampedPinnedCount = max(0, min(pinnedCount, clampedTotalCount)) + + switch placement { + case .top: + // Keep pinned workspaces grouped at the top by inserting ahead of unpinned items. + return clampedPinnedCount + case .end: + return clampedTotalCount + case .afterCurrent: + guard let selectedIndex, clampedTotalCount > 0 else { + return clampedTotalCount + } + let clampedSelectedIndex = max(0, min(selectedIndex, clampedTotalCount - 1)) + if selectedIsPinned { + return clampedPinnedCount + } + return min(clampedSelectedIndex + 1, clampedTotalCount) + } + } +} + struct WorkspaceTabColorEntry: Equatable, Identifiable { let name: String let hex: String @@ -312,47 +353,6 @@ enum WorkspaceTabColorSettings { } } -enum WorkspacePlacementSettings { - static let placementKey = "newWorkspacePlacement" - static let defaultPlacement: NewWorkspacePlacement = .afterCurrent - - static func current(defaults: UserDefaults = .standard) -> NewWorkspacePlacement { - guard let raw = defaults.string(forKey: placementKey), - let placement = NewWorkspacePlacement(rawValue: raw) else { - return defaultPlacement - } - return placement - } - - static func insertionIndex( - placement: NewWorkspacePlacement, - selectedIndex: Int?, - selectedIsPinned: Bool, - pinnedCount: Int, - totalCount: Int - ) -> Int { - let clampedTotalCount = max(0, totalCount) - let clampedPinnedCount = max(0, min(pinnedCount, clampedTotalCount)) - - switch placement { - case .top: - // Keep pinned workspaces grouped at the top by inserting ahead of unpinned items. - return clampedPinnedCount - case .end: - return clampedTotalCount - case .afterCurrent: - guard let selectedIndex, clampedTotalCount > 0 else { - return clampedTotalCount - } - let clampedSelectedIndex = max(0, min(selectedIndex, clampedTotalCount - 1)) - if selectedIsPinned { - return clampedPinnedCount - } - return min(clampedSelectedIndex + 1, clampedTotalCount) - } - } -} - /// Coalesces repeated main-thread signals into one callback after a short delay. /// Useful for notification storms where only the latest update matters. final class NotificationBurstCoalescer { @@ -558,11 +558,6 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback( @MainActor class TabManager: ObservableObject { - private struct InitialWorkspaceGitMetadataSnapshot: Equatable { - let branch: String? - let isDirty: Bool - } - /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). /// Used to apply title updates to the correct window instead of NSApp.keyWindow. weak var window: NSWindow? @@ -574,10 +569,12 @@ class TabManager: ObservableObject { /// Global monotonically increasing counter for CMUX_PORT ordinal assignment. /// Static so port ranges don't overlap across multiple windows (each window has its own TabManager). private static var nextPortOrdinal: Int = 0 - private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] @Published var selectedTabId: UUID? { didSet { guard selectedTabId != oldValue else { return } + sentryBreadcrumb("workspace.switch", data: [ + "tabCount": tabs.count + ]) let previousTabId = oldValue if let previousTabId, let previousPanelId = focusedPanelId(for: previousTabId) { @@ -627,12 +624,6 @@ class TabManager: ObservableObject { private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:] private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) - private let initialWorkspaceGitProbeQueue = DispatchQueue( - label: "com.cmux.initial-workspace-git-probe", - qos: .utility - ) - private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:] - private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:] // Recent tab history for back/forward navigation (like browser history) private var tabHistory: [UUID] = [] @@ -728,34 +719,19 @@ class TabManager: ObservableObject { } var isFindVisible: Bool { - if selectedTerminalPanel?.searchState != nil { return true } - if focusedBrowserPanel?.searchState != nil { return true } - return false + selectedTerminalPanel?.searchState != nil } var canUseSelectionForFind: Bool { - if focusedBrowserPanel != nil { return false } - return selectedTerminalPanel?.hasSelection() == true + selectedTerminalPanel?.hasSelection() == true } func startSearch() { - if let browser = focusedBrowserPanel { - browser.startFind() - return - } - guard let panel = selectedTerminalPanel else { -#if DEBUG - dlog("find.startSearch SKIPPED no selectedTerminalPanel") -#endif - return - } - let wasNil = panel.searchState == nil - if wasNil { + guard let panel = selectedTerminalPanel else { return } + if panel.searchState == nil { panel.searchState = TerminalSurface.SearchState() } -#if DEBUG - dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))") -#endif + NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) _ = panel.performBindingAction("start_search") } @@ -765,26 +741,16 @@ class TabManager: ObservableObject { if panel.searchState == nil { panel.searchState = TerminalSurface.SearchState() } -#if DEBUG - dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))") -#endif + NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) _ = panel.performBindingAction("search_selection") } func findNext() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.findNext() - return - } _ = selectedTerminalPanel?.performBindingAction("search:next") } func findPrevious() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.findPrevious() - return - } _ = selectedTerminalPanel?.performBindingAction("search:previous") } @@ -795,13 +761,6 @@ class TabManager: ObservableObject { } func hideFind() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.hideFind() - return - } -#if DEBUG - dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")") -#endif selectedTerminalPanel?.searchState = nil } @@ -815,8 +774,7 @@ class TabManager: ObservableObject { placementOverride: NewWorkspacePlacement? = nil ) -> Workspace { sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1]) - let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) - let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab() + let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 @@ -835,17 +793,8 @@ class TabManager: ObservableObject { } else { tabs.append(newWorkspace) } - if let explicitWorkingDirectory, - let terminalPanel = newWorkspace.focusedTerminalPanel { - scheduleInitialWorkspaceGitMetadataRefresh( - workspaceId: newWorkspace.id, - panelId: terminalPanel.id, - directory: explicitWorkingDirectory - ) - } if eagerLoadTerminal { - requestBackgroundWorkspaceLoad(for: newWorkspace.id) - newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded() + newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() } if select { selectedTabId = newWorkspace.id @@ -865,182 +814,6 @@ class TabManager: ObservableObject { return newWorkspace } - private func scheduleInitialWorkspaceGitMetadataRefresh( - workspaceId: UUID, - panelId: UUID, - directory: String - ) { - let normalizedDirectory = normalizeDirectory(directory) - let generation = UUID() - cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId) - initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation - -#if DEBUG - dlog( - "workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory)" - ) -#endif - - let delays = Self.initialWorkspaceGitProbeDelays - var timers: [DispatchSourceTimer] = [] - for (index, delay) in delays.enumerated() { - let isLastAttempt = index == delays.count - 1 - let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue) - timer.schedule(deadline: .now() + delay, repeating: .never) - timer.setEventHandler { [weak self] in - let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory) - Task { @MainActor [weak self] in - self?.applyInitialWorkspaceGitMetadataSnapshot( - snapshot, - generation: generation, - workspaceId: workspaceId, - panelId: panelId, - expectedDirectory: normalizedDirectory, - isLastAttempt: isLastAttempt - ) - } - } - timers.append(timer) - timer.resume() - } - initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers - } - - private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) { - guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else { - return - } - for timer in timers { - timer.setEventHandler {} - timer.cancel() - } - } - - private func clearInitialWorkspaceGitProbe(workspaceId: UUID) { - initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId) - cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId) - } - - private func applyInitialWorkspaceGitMetadataSnapshot( - _ snapshot: InitialWorkspaceGitMetadataSnapshot, - generation: UUID, - workspaceId: UUID, - panelId: UUID, - expectedDirectory: String, - isLastAttempt: Bool - ) { - defer { - if isLastAttempt, - initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation { - clearInitialWorkspaceGitProbe(workspaceId: workspaceId) - } - } - - guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return } - guard let workspace = tabs.first(where: { $0.id == workspaceId }) else { - clearInitialWorkspaceGitProbe(workspaceId: workspaceId) - return - } - guard workspace.panels[panelId] != nil else { - clearInitialWorkspaceGitProbe(workspaceId: workspaceId) - return - } - - let currentDirectory = normalizedWorkingDirectory( - workspace.panelDirectories[panelId] ?? workspace.currentDirectory - ) - if let currentDirectory, currentDirectory != expectedDirectory { - clearInitialWorkspaceGitProbe(workspaceId: workspaceId) -#if DEBUG - dlog( - "workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) reason=directoryChanged " + - "expected=\(expectedDirectory) current=\(currentDirectory)" - ) -#endif - return - } - - workspace.updatePanelDirectory(panelId: panelId, directory: expectedDirectory) - - let previousBranch = Self.normalizedBranchName(workspace.panelGitBranches[panelId]?.branch) - let nextBranch = snapshot.branch - if let nextBranch { - workspace.updatePanelGitBranch(panelId: panelId, branch: nextBranch, isDirty: snapshot.isDirty) - } else { - workspace.clearPanelGitBranch(panelId: panelId) - } - - if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) { - workspace.clearPanelPullRequest(panelId: panelId) - } - -#if DEBUG - let branchLabel = snapshot.branch ?? "none" - dlog( - "workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)" - ) -#endif - } - - private nonisolated static func initialWorkspaceGitMetadataSnapshot( - for directory: String - ) -> InitialWorkspaceGitMetadataSnapshot { - let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"])) - guard let branch else { - return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false) - } - - let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"]) - let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty) - } - - private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? { - let process = Process() - let stdout = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["git", "-C", directory] + arguments - process.standardOutput = stdout - process.standardError = FileHandle.nullDevice - - do { - try process.run() - } catch { - return nil - } - - // Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer. - let data = stdout.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - guard process.terminationStatus == 0 else { - return nil - } - - return String(data: data, encoding: .utf8) - } - - private nonisolated static func normalizedBranchName(_ branch: String?) -> String? { - let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? nil : trimmed - } - - func requestBackgroundWorkspaceLoad(for workspaceId: UUID) { - guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return } - } - - func completeBackgroundWorkspaceLoad(for workspaceId: UUID) { - guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return } - } - - func pruneBackgroundWorkspaceLoads(existingIds: Set) { - let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds) - guard pruned != pendingBackgroundWorkspaceLoadIds else { return } - pendingBackgroundWorkspaceLoadIds = pruned - } - // Keep addTab as convenience alias @discardableResult func addTab(select: Bool = true, eagerLoadTerminal: Bool = false) -> Workspace { @@ -1052,6 +825,9 @@ class TabManager: ObservableObject { if let focusedTerminal = workspace.focusedTerminalPanel { return focusedTerminal } + if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() { + return rememberedTerminal + } if let focusedPaneId = workspace.bonsplitController.focusedPaneId, let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) { return paneTerminal @@ -1059,71 +835,19 @@ class TabManager: ObservableObject { return workspace.terminalPanelForConfigInheritance() } - func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { - let workspaceSnapshots = tabs - .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) - .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } - let selectedWorkspaceIndex = selectedTabId.flatMap { selectedId in - tabs.firstIndex(where: { $0.id == selectedId }) - } - return SessionTabManagerSnapshot( - selectedWorkspaceIndex: selectedWorkspaceIndex, - workspaces: workspaceSnapshots - ) - } - - func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) { - for tab in tabs { - unwireClosedBrowserTracking(for: tab) - } - - tabs.removeAll(keepingCapacity: false) - lastFocusedPanelByTab.removeAll() - pendingPanelTitleUpdates.removeAll() - tabHistory.removeAll() - historyIndex = -1 - isNavigatingHistory = false - pendingWorkspaceUnfocusTarget = nil - workspaceCycleCooldownTask?.cancel() - workspaceCycleCooldownTask = nil - isWorkspaceCycleHot = false - selectionSideEffectsGeneration &+= 1 - recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20) - - let workspaceSnapshots = snapshot.workspaces - .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) - for workspaceSnapshot in workspaceSnapshots { - let ordinal = Self.nextPortOrdinal - Self.nextPortOrdinal += 1 - let workspace = Workspace( - title: workspaceSnapshot.processTitle, - workingDirectory: workspaceSnapshot.currentDirectory, - portOrdinal: ordinal - ) - workspace.restoreSessionSnapshot(workspaceSnapshot) - wireClosedBrowserTracking(for: workspace) - tabs.append(workspace) - } - - if tabs.isEmpty { - _ = addWorkspace(select: false) - } - - selectedTabId = nil - if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex, - tabs.indices.contains(selectedWorkspaceIndex) { - selectedTabId = tabs[selectedWorkspaceIndex].id - } else { - selectedTabId = tabs.first?.id - } - - if let selectedTabId { - NotificationCenter.default.post( - name: .ghosttyDidFocusTab, - object: nil, - userInfo: [GhosttyNotificationKey.tabId: selectedTabId] + private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? { + if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface { + return cmuxInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_TAB ) } + if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() { + var config = ghostty_surface_config_new() + config.font_size = fallbackFontPoints + return config + } + return nil } private func normalizedWorkingDirectory(_ directory: String?) -> String? { @@ -1171,16 +895,6 @@ class TabManager: ObservableObject { tabs.insert(tab, at: insertIndex) } - func moveTabToTopForNotification(_ tabId: UUID) { - guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } - let pinnedCount = tabs.filter { $0.isPinned }.count - guard index != pinnedCount else { return } - let tab = tabs[index] - guard !tab.isPinned else { return } - tabs.remove(at: index) - tabs.insert(tab, at: pinnedCount) - } - func moveTabsToTop(_ tabIds: Set) { guard !tabIds.isEmpty else { return } let selectedTabs = tabs.filter { tabIds.contains($0.id) } @@ -1193,6 +907,16 @@ class TabManager: ObservableObject { tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned } + func moveTabToTopForNotification(_ tabId: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + let pinnedCount = tabs.filter { $0.isPinned }.count + guard index != pinnedCount else { return } + let tab = tabs[index] + guard !tab.isPinned else { return } + tabs.remove(at: index) + tabs.insert(tab, at: pinnedCount) + } + @discardableResult func reorderWorkspace(tabId: UUID, toIndex targetIndex: Int) -> Bool { guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } @@ -1232,6 +956,11 @@ class TabManager: ObservableObject { setCustomTitle(tabId: tabId, title: nil) } + func setTabColor(tabId: UUID, color: String?) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + tab.setCustomColor(color) + } + func togglePin(tabId: UUID) { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } let tab = tabs[index] @@ -1244,11 +973,6 @@ class TabManager: ObservableObject { reorderTabForPinnedState(tab) } - func setTabColor(tabId: UUID, color: String?) { - guard let tab = tabs.first(where: { $0.id == tabId }) else { return } - tab.setCustomColor(color) - } - private func reorderTabForPinnedState(_ tab: Workspace) { guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return } tabs.remove(at: index) @@ -1276,25 +1000,38 @@ class TabManager: ObservableObject { return trimmed } + func requestBackgroundWorkspaceLoad(for workspaceId: UUID) { + _ = pendingBackgroundWorkspaceLoadIds.insert(workspaceId) + } + + func completeBackgroundWorkspaceLoad(for workspaceId: UUID) { + pendingBackgroundWorkspaceLoadIds.remove(workspaceId) + } + + func pruneBackgroundWorkspaceLoads(existingIds: Set) { + let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds) + guard pruned != pendingBackgroundWorkspaceLoadIds else { return } + pendingBackgroundWorkspaceLoadIds = pruned + } + func closeWorkspace(_ workspace: Workspace) { guard tabs.count > 1 else { return } - guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) - clearInitialWorkspaceGitProbe(workspaceId: workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) workspace.teardownRemoteConnection() unwireClosedBrowserTracking(for: workspace) - workspace.teardownAllPanels() - tabs.remove(at: index) + if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { + tabs.remove(at: index) - if selectedTabId == workspace.id { - // Keep the "focused index" stable when possible: - // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). - // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). - let newIndex = min(index, max(0, tabs.count - 1)) - selectedTabId = tabs[newIndex].id + if selectedTabId == workspace.id { + // Keep the "focused index" stable when possible: + // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). + // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). + let newIndex = min(index, max(0, tabs.count - 1)) + selectedTabId = tabs[newIndex].id + } } } @@ -1303,7 +1040,6 @@ class TabManager: ObservableObject { @discardableResult func detachWorkspace(tabId: UUID) -> Workspace? { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } - clearInitialWorkspaceGitProbe(workspaceId: tabId) let removed = tabs.remove(at: index) unwireClosedBrowserTracking(for: removed) @@ -1365,13 +1101,9 @@ class TabManager: ObservableObject { let count = plan.panelIds.count let titleLines = plan.titles.map { "• \($0)" }.joined(separator: "\n") - let message = if count == 1 { - String(localized: "dialog.closeOtherTabs.message.one", defaultValue: "This will close 1 tab in this pane:\n\(titleLines)") - } else { - String(localized: "dialog.closeOtherTabs.message.other", defaultValue: "This will close \(count) tabs in this pane:\n\(titleLines)") - } + let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)" guard confirmClose( - title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), + title: "Close other tabs?", message: message, acceptCmdD: false ) else { return } @@ -1411,8 +1143,8 @@ class TabManager: ObservableObject { alert.messageText = title alert.informativeText = message alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") // macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save"). // We only opt into this for the "close last workspace => close window" path to avoid @@ -1473,15 +1205,15 @@ class TabManager: ObservableObject { if let collapsed, !collapsed.isEmpty { return collapsed } - return String(localized: "tab.untitled", defaultValue: "Untitled Tab") + return "Untitled Tab" } private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) { let willCloseWindow = tabs.count <= 1 if workspaceNeedsConfirmClose(workspace), !confirmClose( - title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), - message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."), + title: "Close workspace?", + message: "This will close the workspace and all of its panels.", acceptCmdD: willCloseWindow ) { return @@ -1522,8 +1254,8 @@ class TabManager: ObservableObject { let needsConfirm = workspaceNeedsConfirmClose(tab) if needsConfirm { let message = willCloseWindow - ? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.") - : String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.") + ? "This will close the last tab and close the window." + : "This will close the last tab and close its workspace." #if DEBUG dlog( "surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " + @@ -1531,7 +1263,7 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), + title: "Close tab?", message: message, acceptCmdD: willCloseWindow ) else { @@ -1563,8 +1295,8 @@ class TabManager: ObservableObject { ) #endif guard confirmClose( - title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), - message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), + title: "Close tab?", + message: "This will close the current tab.", acceptCmdD: false ) else { #if DEBUG @@ -1602,8 +1334,8 @@ class TabManager: ObservableObject { if let terminalPanel = tab.terminalPanel(for: surfaceId), terminalPanel.needsConfirmClose() { guard confirmClose( - title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), - message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), + title: "Close tab?", + message: "This will close the current tab.", acceptCmdD: false ) else { return } } @@ -1618,11 +1350,24 @@ class TabManager: ObservableObject { guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard tab.panels[surfaceId] != nil else { return } +#if DEBUG + dlog( + "surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " + + "surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)" + ) +#endif + // Keep AppKit first responder in sync with workspace focus before routing the close. // If split reparenting caused a temporary model/view mismatch, fallback close logic in // Workspace.closePanel uses focused selection to resolve the correct tab deterministically. reconcileFocusedPanelFromFirstResponderForKeyboard() - _ = tab.closePanel(surfaceId, force: true) + let closed = tab.closePanel(surfaceId, force: true) +#if DEBUG + dlog( + "surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " + + "surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)" + ) +#endif AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId) } @@ -1631,6 +1376,33 @@ class TabManager: ObservableObject { /// This should never prompt: the process is already gone, and Ghostty emits the /// `SHOW_CHILD_EXITED` action specifically so the host app can decide what to do. func closePanelAfterChildExited(tabId: UUID, surfaceId: UUID) { + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + guard tab.panels[surfaceId] != nil else { return } + +#if DEBUG + dlog( + "surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " + + "surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count)" + ) +#endif + + // Child-exit on the last panel should collapse the workspace, matching explicit close + // semantics (and close the window when it was the last workspace). + if tab.panels.count <= 1 { + if tabs.count <= 1 { + if let app = AppDelegate.shared { + app.notificationStore?.clearNotifications(forTabId: tabId) + app.closeMainWindowContainingTabId(tabId) + } else { + // Headless/test fallback when no AppDelegate window context exists. + closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) + } + } else { + closeWorkspace(tab) + } + return + } + closeRuntimeSurface(tabId: tabId, surfaceId: surfaceId) } @@ -1830,32 +1602,28 @@ class TabManager: ObservableObject { guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) } private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } - _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + guard AppFocusState.isAppActive() else { return } + guard let notificationStore = AppDelegate.shared?.notificationStore else { return } + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return } + if let tab = tabs.first(where: { $0.id == tabId }) { + tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) + } + notificationStore.markRead(forTabId: tabId, surfaceId: panelId) } @discardableResult func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool { - dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true) - } - - @discardableResult - private func dismissNotificationIfActive( - tabId: UUID, - surfaceId: UUID?, - triggerFlash: Bool - ) -> Bool { guard selectedTabId == tabId else { return false } guard AppFocusState.isAppActive() else { return false } guard let notificationStore = AppDelegate.shared?.notificationStore else { return false } guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } - if triggerFlash, - let panelId = surfaceId, + if let panelId = surfaceId, let tab = tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) } @@ -1914,8 +1682,8 @@ class TabManager: ObservableObject { private func updateWindowTitle(for tab: Workspace?) { let title = windowTitle(for: tab) - let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first - targetWindow?.title = title + guard let targetWindow = window else { return } + targetWindow.title = title } private func windowTitle(for tab: Workspace?) -> String { @@ -1929,7 +1697,11 @@ class TabManager: ObservableObject { } func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) { - guard tabs.contains(where: { $0.id == tabId }) else { return } + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + if let surfaceId, tab.panels[surfaceId] != nil { + // Keep selected-surface intent stable across selectedTabId didSet async restore. + lastFocusedPanelByTab[tabId] = surfaceId + } selectedTabId = tabId NotificationCenter.default.post( name: .ghosttyDidFocusTab, @@ -1937,10 +1709,15 @@ class TabManager: ObservableObject { userInfo: [GhosttyNotificationKey.tabId: tabId] ) - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + guard let self else { return } NSApp.activate(ignoringOtherApps: true) NSApp.unhide(nil) - if let window = NSApp.keyWindow ?? NSApp.windows.first { + if let app = AppDelegate.shared, + let windowId = app.windowId(for: self), + let window = app.mainWindow(for: windowId) { + window.makeKeyAndOrderFront(nil) + } else if let window = NSApp.keyWindow ?? NSApp.windows.first { window.makeKeyAndOrderFront(nil) } } @@ -1948,7 +1725,7 @@ class TabManager: ObservableObject { if let surfaceId { if !suppressFlash { focusSurface(tabId: tabId, surfaceId: surfaceId) - } else if let tab = tabs.first(where: { $0.id == tabId }) { + } else { tab.focusPanel(surfaceId) } } @@ -2145,24 +1922,9 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return } -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.create.request kind=terminal dir=\(directionLabel) " + - "tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif tab.clearSplitZoom() sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) - let createdPanelId = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) -#if DEBUG - dlog( - "split.create.result kind=terminal dir=\(directionLabel) " + - "created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif + _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } /// Create a new browser split from the currently focused panel. @@ -2171,30 +1933,14 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return nil } -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.create.request kind=browser dir=\(directionLabel) " + - "tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif tab.clearSplitZoom() - let createdPanelId = newBrowserSplit( + return newBrowserSplit( tabId: selectedTabId, fromPanelId: focusedPanelId, orientation: direction.orientation, insertFirst: direction.insertFirst, url: url ) -#if DEBUG - dlog( - "split.create.result kind=browser dir=\(directionLabel) " + - "created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif - return createdPanelId } /// Refresh Bonsplit right-side action button tooltips for all workspaces. @@ -2293,22 +2039,14 @@ class TabManager: ObservableObject { /// Create a new split in the specified direction /// Returns the new panel's ID (which is also the surface ID for terminals) - func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection) -> UUID? { + func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - let createdPanel = tab.newTerminalSplit( + return tab.newTerminalSplit( from: surfaceId, orientation: direction.orientation, - insertFirst: direction.insertFirst + insertFirst: direction.insertFirst, + focus: focus )?.id -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.newSurface result dir=\(directionLabel) " + - "tab=\(tabId.uuidString.prefix(5)) source=\(surfaceId.uuidString.prefix(5)) " + - "created=\(createdPanel?.uuidString.prefix(5) ?? "nil") focus=\(focus ? 1 : 0)" - ) -#endif - return createdPanel } /// Move focus in the specified direction @@ -2409,14 +2147,16 @@ class TabManager: ObservableObject { fromPanelId: UUID, orientation: SplitOrientation, insertFirst: Bool = false, - url: URL? = nil + url: URL? = nil, + focus: Bool = true ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } return tab.newBrowserSplit( from: fromPanelId, orientation: orientation, insertFirst: insertFirst, - url: url + url: url, + focus: focus )?.id } @@ -2509,6 +2249,8 @@ class TabManager: ObservableObject { ) } + /// Reopen the most recently closed browser panel (Cmd+Shift+T). + /// No-op when no browser panel restore snapshot is available. @discardableResult func reopenMostRecentlyClosedBrowserPanel() -> Bool { while let snapshot = recentlyClosedBrowsers.pop() { @@ -2905,7 +2647,7 @@ class TabManager: ObservableObject { continue } terminal.hostedView.reconcileGeometryNow() - terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry") + terminal.surface.forceRefresh() } } @@ -3387,6 +3129,10 @@ class TabManager: ObservableObject { let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1" let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") .trimmingCharacters(in: .whitespacesAndNewlines) + let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d" + let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d" + let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger + let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") .trimmingCharacters(in: .whitespacesAndNewlines) let expectedPanelsAfter = max( @@ -3660,8 +3406,11 @@ class TabManager: ObservableObject { return } - // Wait for the target panel to be fully attached after split churn. - let readyDeadline = Date().addingTimeInterval(2.0) + let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift + ? [.control, .shift] + : [.control] + let shouldWaitForSurface = !useEarlyTrigger + var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false if shouldWaitForSurface { @@ -3680,12 +3429,9 @@ class TabManager: ObservableObject { } try? await Task.sleep(nanoseconds: 50_000_000) } + } else if let panel = tab.terminalPanel(for: exitPanelId) { attachedBeforeTrigger = panel.hostedView.window != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil - if attachedBeforeTrigger, hasSurfaceBeforeTrigger { - break - } - try? await Task.sleep(nanoseconds: 50_000_000) } write([ "exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0", @@ -3701,7 +3447,7 @@ class TabManager: ObservableObject { return } // Exercise the real key path (ghostty_surface_key for Ctrl+D). - if panel.hostedView.sendSyntheticCtrlDForUITest() { + if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { write(["autoTriggerSentCtrlDKey1": "1"]) } else { write([ @@ -3713,13 +3459,20 @@ class TabManager: ObservableObject { // In strict mode, never mask routing bugs with fallback writes. if strictKeyOnly { - write(["autoTriggerMode": "strict_ctrl_d"]) + let strictModeLabel: String = { + if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" } + if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" } + if triggerUsesShift { return "strict_ctrl_shift_d" } + return "strict_ctrl_d" + }() + write(["autoTriggerMode": strictModeLabel]) return } // Non-strict mode keeps one additional Ctrl+D retry for startup timing variance. try? await Task.sleep(nanoseconds: 450_000_000) - if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest() { + if tab.panels[exitPanelId] != nil, + panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) { write(["autoTriggerSentCtrlDKey2": "1"]) } } @@ -3852,6 +3605,7 @@ extension TabManager { } } } + // MARK: - Direction Types for Backwards Compatibility /// Split direction for backwards compatibility with old API @@ -3871,15 +3625,6 @@ enum SplitDirection { var insertFirst: Bool { self == .left || self == .up } - - var debugLabel: String { - switch self { - case .left: return "left" - case .right: return "right" - case .up: return "up" - case .down: return "down" - } - } } /// Resize direction for backwards compatibility @@ -3903,12 +3648,11 @@ extension Notification.Name { static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface") + static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView") static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar") static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection") static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") - static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") - static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink") } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index fdbf254c..52a02145 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4559,19 +4559,12 @@ class TerminalController { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() queued = true } - - for char in text { - if char.unicodeScalars.count == 1, - let scalar = char.unicodeScalars.first, - handleControlScalar(scalar, surface: surface) { - continue - } - sendTextEvent(surface: surface, text: String(char)) - } - // Ensure we present a new frame after injecting input so snapshot-based tests (and - // socket-driven agents) can observe the updated terminal without requiring a focus - // change to trigger a draw. - terminalPanel.surface.forceRefresh() +#if DEBUG + let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 + dlog( + "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))" + ) +#endif result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result @@ -4809,6 +4802,23 @@ class TerminalController { return "OK \(base64)" } + func readTerminalTextForSessionSnapshot( + terminalPanel: TerminalPanel, + includeScrollback: Bool = false, + lineLimit: Int? = nil + ) -> String? { + let response = readTerminalTextBase64( + terminalPanel: terminalPanel, + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + guard response.hasPrefix("OK ") else { return nil } + let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !payload.isEmpty else { return "" } + guard let data = Data(base64Encoded: payload) else { return nil } + return String(decoding: data, as: UTF8.self) + } + private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -12033,6 +12043,29 @@ class TerminalController { return success ? "OK" : "ERROR: Failed to send input" } + private func sendSocketText(_ text: String, surface: ghostty_surface_t) { + let chunks = Self.socketTextChunks(text) +#if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime +#endif + for chunk in chunks { + switch chunk { + case .text(let value): + sendTextEvent(surface: surface, text: value) + case .control(let scalar): + _ = handleControlScalar(scalar, surface: surface) + } + } +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + if elapsedMs >= 8 || chunks.count > 1 { + dlog( + "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" + ) + } +#endif + } + private func sendInputToWorkspace(_ args: String) -> String { guard let tabManager else { return "ERROR: TabManager not available" } let parts = args.split(separator: " ", maxSplits: 1).map(String.init) diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 52d9ff26..462b036f 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -94,7 +94,7 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { let text: String if let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) { - let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines) + let title = tab.title.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) text = title.isEmpty ? "Cmd: —" : "Cmd: \(title)" } else { text = "Cmd: —" diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c477a62c..ffad398e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -106,1586 +106,7 @@ private struct SessionPaneRestoreEntry { let snapshot: SessionPaneLayoutSnapshot } -private final class WorkspaceRemoteSessionController { - private struct ForwardEntry { - let process: Process - let stderrPipe: Pipe - } - - private struct CommandResult { - let status: Int32 - let stdout: String - let stderr: String - } - - private struct RemotePlatform { - let goOS: String - let goArch: String - } - - private struct DaemonHello { - let name: String - let version: String - let capabilities: [String] - let remotePath: String - } - - private let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility) - private weak var workspace: Workspace? - private let configuration: WorkspaceRemoteConfiguration - - private var isStopping = false - private var probeProcess: Process? - private var probeStdoutPipe: Pipe? - private var probeStderrPipe: Pipe? - private var probeStdoutBuffer = "" - private var probeStderrBuffer = "" - - private var desiredRemotePorts: Set = [] - private var forwardEntries: [Int: ForwardEntry] = [:] - private var portConflicts: Set = [] - private var daemonReady = false - private var daemonBootstrapVersion: String? - private var daemonRemotePath: String? - private var reconnectRetryCount = 0 - private var reconnectWorkItem: DispatchWorkItem? - private var reverseRelayProcess: Process? - private var reverseRelayStderrPipe: Pipe? - - init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration) { - self.workspace = workspace - self.configuration = configuration - } - - func start() { - queue.async { [weak self] in - guard let self else { return } - guard !self.isStopping else { return } - self.beginConnectionAttemptLocked() - } - } - - func stop() { - queue.async { [weak self] in - self?.stopAllLocked() - } - } - - private func stopAllLocked() { - isStopping = true - reconnectWorkItem?.cancel() - reconnectWorkItem = nil - reconnectRetryCount = 0 - - if let probeProcess { - probeStdoutPipe?.fileHandleForReading.readabilityHandler = nil - probeStderrPipe?.fileHandleForReading.readabilityHandler = nil - if probeProcess.isRunning { - probeProcess.terminate() - } - } - probeProcess = nil - probeStdoutPipe = nil - probeStderrPipe = nil - probeStdoutBuffer = "" - probeStderrBuffer = "" - - if let reverseRelayProcess { - reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil - if reverseRelayProcess.isRunning { - reverseRelayProcess.terminate() - } - } - reverseRelayProcess = nil - reverseRelayStderrPipe = nil - - for (_, entry) in forwardEntries { - entry.stderrPipe.fileHandleForReading.readabilityHandler = nil - if entry.process.isRunning { - entry.process.terminate() - } - } - forwardEntries.removeAll() - desiredRemotePorts.removeAll() - portConflicts.removeAll() - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - } - - private func beginConnectionAttemptLocked() { - guard !isStopping else { return } - - reconnectWorkItem = nil - let connectDetail: String - let bootstrapDetail: String - if reconnectRetryCount > 0 { - connectDetail = "Reconnecting to \(configuration.displayTarget) (retry \(reconnectRetryCount))" - bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget) (retry \(reconnectRetryCount))" - } else { - connectDetail = "Connecting to \(configuration.displayTarget)" - bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget)" - } - publishState(.connecting, detail: connectDetail) - publishDaemonStatus(.bootstrapping, detail: bootstrapDetail) - - do { - let hello = try bootstrapDaemonLocked() - daemonReady = true - daemonBootstrapVersion = hello.version - daemonRemotePath = hello.remotePath - publishDaemonStatus( - .ready, - detail: "Remote daemon ready", - version: hello.version, - name: hello.name, - capabilities: hello.capabilities, - remotePath: hello.remotePath - ) - startReverseRelayLocked() - startProbeLocked() - } catch { - daemonReady = false - daemonBootstrapVersion = nil - daemonRemotePath = nil - let nextRetry = scheduleProbeRestartLocked(delay: 4.0) - let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 4.0) - let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)" - publishDaemonStatus(.error, detail: detail) - publishState(.error, detail: detail) - } - } - - private func startProbeLocked() { - guard !isStopping else { return } - guard daemonReady else { return } - - probeStdoutBuffer = "" - probeStderrBuffer = "" - - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = probeArguments() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - if data.isEmpty { - handle.readabilityHandler = nil - return - } - self?.queue.async { - self?.consumeProbeStdoutData(data) - } - } - - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - if data.isEmpty { - handle.readabilityHandler = nil - return - } - self?.queue.async { - self?.consumeProbeStderrData(data) - } - } - - process.terminationHandler = { [weak self] terminated in - self?.queue.async { - self?.handleProbeTermination(terminated) - } - } - - do { - try process.run() - probeProcess = process - probeStdoutPipe = stdoutPipe - probeStderrPipe = stderrPipe - } catch { - let nextRetry = scheduleProbeRestartLocked(delay: 3.0) - let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 3.0) - publishState(.error, detail: "Failed to start SSH probe: \(error.localizedDescription)\(retrySuffix)") - } - } - - private func handleProbeTermination(_ process: Process) { - probeStdoutPipe?.fileHandleForReading.readabilityHandler = nil - probeStderrPipe?.fileHandleForReading.readabilityHandler = nil - probeProcess = nil - probeStdoutPipe = nil - probeStderrPipe = nil - - guard !isStopping else { return } - - for (_, entry) in forwardEntries { - entry.stderrPipe.fileHandleForReading.readabilityHandler = nil - if entry.process.isRunning { - entry.process.terminate() - } - } - forwardEntries.removeAll() - publishPortsSnapshotLocked() - - let statusCode = process.terminationStatus - let rawDetail = Self.bestErrorLine(stderr: probeStderrBuffer, stdout: probeStdoutBuffer) - let detail = rawDetail ?? "SSH probe exited with status \(statusCode)" - let nextRetry = scheduleProbeRestartLocked(delay: 3.0) - let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 3.0) - publishState(.error, detail: "SSH probe to \(configuration.displayTarget) failed: \(detail)\(retrySuffix)") - } - - @discardableResult - private func scheduleProbeRestartLocked(delay: TimeInterval) -> Int { - guard !isStopping else { return reconnectRetryCount } - reconnectWorkItem?.cancel() - reconnectRetryCount += 1 - let retryNumber = reconnectRetryCount - let workItem = DispatchWorkItem { [weak self] in - guard let self else { return } - self.reconnectWorkItem = nil - guard !self.isStopping else { return } - guard self.probeProcess == nil else { return } - self.beginConnectionAttemptLocked() - } - reconnectWorkItem = workItem - queue.asyncAfter(deadline: .now() + delay, execute: workItem) - return retryNumber - } - - private func consumeProbeStdoutData(_ data: Data) { - guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return } - probeStdoutBuffer.append(chunk) - - while let newline = probeStdoutBuffer.firstIndex(of: "\n") { - let line = String(probeStdoutBuffer[.. 8192 { - probeStderrBuffer.removeFirst(probeStderrBuffer.count - 8192) - } - } - - private func handleProbePortsLine(_ line: String) { - guard !isStopping else { return } - - var ports = Set(Self.parseRemotePorts(line: line)) - if let relayPort = configuration.relayPort { - ports.remove(relayPort) - } - // Filter ephemeral ports (49152-65535) — these are SSH reverse relay ports - // from this or other workspaces, not user services worth forwarding. - ports = ports.filter { $0 < 49152 } - desiredRemotePorts = ports - portConflicts = portConflicts.intersection(desiredRemotePorts) - reconnectWorkItem?.cancel() - reconnectWorkItem = nil - reconnectRetryCount = 0 - publishState(.connected, detail: "Connected to \(configuration.displayTarget)") - reconcileForwardsLocked() - } - - private func reconcileForwardsLocked() { - guard !isStopping else { return } - - for (port, entry) in forwardEntries where !desiredRemotePorts.contains(port) { - entry.stderrPipe.fileHandleForReading.readabilityHandler = nil - if entry.process.isRunning { - entry.process.terminate() - } - forwardEntries.removeValue(forKey: port) - } - - for port in desiredRemotePorts.sorted() where forwardEntries[port] == nil { - guard Self.isLoopbackPortAvailable(port: port) else { - // Port is already bound locally. If it's reachable (e.g. another - // workspace is forwarding it), don't flag it as a conflict. - if Self.isLoopbackPortReachable(port: port) { - portConflicts.remove(port) - } else { - portConflicts.insert(port) - } - continue - } - if startForwardLocked(port: port) { - portConflicts.remove(port) - } else { - portConflicts.insert(port) - } - } - - publishPortsSnapshotLocked() - } - - @discardableResult - private func startForwardLocked(port: Int) -> Bool { - guard !isStopping else { return false } - - let process = Process() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - process.arguments = forwardArguments(port: port) - process.standardOutput = FileHandle.nullDevice - process.standardError = stderrPipe - - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { - handle.readabilityHandler = nil - return - } - self?.queue.async { - guard let self else { return } - if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { - self.probeStderrBuffer.append(chunk) - if self.probeStderrBuffer.count > 8192 { - self.probeStderrBuffer.removeFirst(self.probeStderrBuffer.count - 8192) - } - } - } - } - - process.terminationHandler = { [weak self] terminated in - self?.queue.async { - self?.handleForwardTermination(port: port, process: terminated) - } - } - - do { - try process.run() - forwardEntries[port] = ForwardEntry(process: process, stderrPipe: stderrPipe) - return true - } catch { - publishState(.error, detail: "Failed to forward local :\(port) to \(configuration.displayTarget): \(error.localizedDescription)") - return false - } - } - - private func handleForwardTermination(port: Int, process: Process) { - if let current = forwardEntries[port], current.process === process { - current.stderrPipe.fileHandleForReading.readabilityHandler = nil - forwardEntries.removeValue(forKey: port) - } - - guard !isStopping else { return } - publishPortsSnapshotLocked() - - guard desiredRemotePorts.contains(port) else { return } - let rawDetail = Self.bestErrorLine(stderr: probeStderrBuffer) - if process.terminationReason != .exit || process.terminationStatus != 0 { - let detail = rawDetail ?? "process exited with status \(process.terminationStatus)" - publishState(.error, detail: "SSH port-forward :\(port) dropped for \(configuration.displayTarget): \(detail)") - } - guard Self.isLoopbackPortAvailable(port: port) else { - portConflicts.insert(port) - publishPortsSnapshotLocked() - return - } - - queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in - guard let self else { return } - guard !self.isStopping else { return } - guard self.desiredRemotePorts.contains(port) else { return } - guard self.forwardEntries[port] == nil else { return } - if self.startForwardLocked(port: port) { - self.portConflicts.remove(port) - } else { - self.portConflicts.insert(port) - } - self.publishPortsSnapshotLocked() - } - } - - /// Spawns a background SSH process that reverse-forwards a remote TCP port to the local cmux Unix socket. - /// This process is a direct child of the cmux app, so it passes the `isDescendant()` ancestry check. - @discardableResult - private func startReverseRelayLocked() -> Bool { - guard !isStopping else { return false } - guard let relayPort = configuration.relayPort, relayPort > 0, - let localSocketPath = configuration.localSocketPath, !localSocketPath.isEmpty else { - return false - } - - // Kill any existing relay process managed by this session - if let existing = reverseRelayProcess { - reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil - if existing.isRunning { existing.terminate() } - reverseRelayProcess = nil - reverseRelayStderrPipe = nil - } - - // Kill orphaned relay SSH processes from previous app sessions that reverse-forward - // to the same socket path (they survive pkill because they're reparented to launchd). - Self.killOrphanedRelayProcesses( - relayPort: relayPort, - socketPath: localSocketPath, - destination: configuration.destination - ) - - let process = Process() - let stderrPipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") - - // Build arguments: -N (no remote command), -o ControlPath=none (avoid ControlMaster delegation), - // then common SSH args, then -R reverse forward, then destination. - // ExitOnForwardFailure=no because user's ~/.ssh/config may have RemoteForward entries - // that conflict with already-bound ports — we don't want those to kill our relay. - var args: [String] = ["-N"] - args += sshCommonArguments(batchMode: true) - args += ["-R", "127.0.0.1:\(relayPort):\(localSocketPath)"] - args += [configuration.destination] - process.arguments = args - - process.standardOutput = FileHandle.nullDevice - process.standardError = stderrPipe - - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let data = handle.availableData - guard !data.isEmpty else { - handle.readabilityHandler = nil - return - } - self?.queue.async { - guard let self else { return } - if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { - self.probeStderrBuffer.append(chunk) - if self.probeStderrBuffer.count > 8192 { - self.probeStderrBuffer.removeFirst(self.probeStderrBuffer.count - 8192) - } - } - } - } - - process.terminationHandler = { [weak self] terminated in - self?.queue.async { - self?.handleReverseRelayTermination(process: terminated) - } - } - - do { - try process.run() - reverseRelayProcess = process - reverseRelayStderrPipe = stderrPipe - NSLog("[cmux] reverse relay started: -R 127.0.0.1:%d:%@ → %@", relayPort, localSocketPath, configuration.destination) - - // Write socket_addr after a delay to give the SSH -R forward time to establish. - // The Go CLI retry loop re-reads this file, so it will pick up the port once ready. - queue.asyncAfter(deadline: .now() + 3.0) { [weak self] in - guard let self, !self.isStopping else { return } - guard self.reverseRelayProcess?.isRunning == true else { return } - self.writeRemoteSocketAddrLocked() - self.writeRemoteRelayDaemonMappingLocked() - } - - return true - } catch { - NSLog("[cmux] failed to start reverse relay: %@", error.localizedDescription) - return false - } - } - - private func handleReverseRelayTermination(process: Process) { - if reverseRelayProcess === process { - reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil - reverseRelayProcess = nil - reverseRelayStderrPipe = nil - } - - guard !isStopping else { return } - guard configuration.relayPort != nil else { return } - - // Auto-restart after 2 seconds if we're still active - queue.asyncAfter(deadline: .now() + 2.0) { [weak self] in - guard let self else { return } - guard !self.isStopping else { return } - guard self.reverseRelayProcess == nil else { return } - self.startReverseRelayLocked() - } - } - - private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - workspace.applyRemoteConnectionStateUpdate( - state, - detail: detail, - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - private func publishDaemonStatus( - _ state: WorkspaceRemoteDaemonState, - detail: String?, - version: String? = nil, - name: String? = nil, - capabilities: [String] = [], - remotePath: String? = nil - ) { - let status = WorkspaceRemoteDaemonStatus( - state: state, - detail: detail, - version: version, - name: name, - capabilities: capabilities, - remotePath: remotePath - ) - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - workspace.applyRemoteDaemonStatusUpdate( - status, - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - private func publishPortsSnapshotLocked() { - let detected = desiredRemotePorts.sorted() - let forwarded = forwardEntries.keys.sorted() - let conflicts = portConflicts.sorted() - DispatchQueue.main.async { [weak workspace] in - guard let workspace else { return } - workspace.applyRemotePortsSnapshot( - detected: detected, - forwarded: forwarded, - conflicts: conflicts, - target: workspace.remoteDisplayTarget ?? "remote host" - ) - } - } - - private func probeArguments() -> [String] { - let remoteScript = Self.probeScript() - let remoteCommand = "sh -lc \(Self.shellSingleQuoted(remoteScript))" - return sshCommonArguments(batchMode: true) + [configuration.destination, remoteCommand] - } - - private func forwardArguments(port: Int) -> [String] { - let localBind = "127.0.0.1:\(port):127.0.0.1:\(port)" - return ["-N"] + sshCommonArguments(batchMode: true) + ["-L", localBind, configuration.destination] - } - - private func sshCommonArguments(batchMode: Bool) -> [String] { - var args: [String] = [ - "-o", "ConnectTimeout=6", - "-o", "ServerAliveInterval=20", - "-o", "ServerAliveCountMax=2", - "-o", "StrictHostKeyChecking=accept-new", - "-o", "ExitOnForwardFailure=no", - "-o", "ControlPath=none", - ] - if batchMode { - args += ["-o", "BatchMode=yes"] - } - if let port = configuration.port { - args += ["-p", String(port)] - } - if let identityFile = configuration.identityFile, - !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args += ["-i", identityFile] - } - for option in configuration.sshOptions { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - args += ["-o", trimmed] - } - return args - } - - private func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { - try runProcess( - executable: "/usr/bin/ssh", - arguments: arguments, - stdin: stdin, - timeout: timeout - ) - } - - private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult { - try runProcess( - executable: "/usr/bin/scp", - arguments: arguments, - stdin: nil, - timeout: timeout - ) - } - - private func runProcess( - executable: String, - arguments: [String], - environment: [String: String]? = nil, - currentDirectory: URL? = nil, - stdin: Data?, - timeout: TimeInterval - ) throws -> CommandResult { - let process = Process() - process.executableURL = URL(fileURLWithPath: executable) - process.arguments = arguments - if let environment { - process.environment = environment - } - if let currentDirectory { - process.currentDirectoryURL = currentDirectory - } - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - if stdin != nil { - process.standardInput = Pipe() - } else { - process.standardInput = FileHandle.nullDevice - } - - do { - try process.run() - } catch { - throw NSError(domain: "cmux.remote.process", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", - ]) - } - - if let stdin, let pipe = process.standardInput as? Pipe { - pipe.fileHandleForWriting.write(stdin) - try? pipe.fileHandleForWriting.close() - } - - let deadline = Date().addingTimeInterval(timeout) - while process.isRunning && Date() < deadline { - Thread.sleep(forTimeInterval: 0.05) - } - if process.isRunning { - process.terminate() - throw NSError(domain: "cmux.remote.process", code: 2, userInfo: [ - NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", - ]) - } - - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - let stdout = String(data: stdoutData, encoding: .utf8) ?? "" - let stderr = String(data: stderrData, encoding: .utf8) ?? "" - return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) - } - - private func bootstrapDaemonLocked() throws -> DaemonHello { - let platform = try resolveRemotePlatformLocked() - let version = Self.remoteDaemonVersion() - let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) - - if try !remoteDaemonExistsLocked(remotePath: remotePath) { - let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) - try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) - } - - createRemoteCLISymlinkLocked(daemonRemotePath: remotePath) - - return try helloRemoteDaemonLocked(remotePath: remotePath) - } - - /// Installs a stable `cmux` wrapper on the remote and updates the default daemon target. - /// The wrapper resolves daemon path using relay-port metadata, allowing multiple local - /// cmux versions to coexist on the same remote host without clobbering each other. - /// Tries `/usr/local/bin` first (already in PATH, no rc changes needed), falls back to - /// `~/.cmux/bin`. Non-fatal: logs on failure but does not throw. - private func createRemoteCLISymlinkLocked(daemonRemotePath: String) { - let script = """ - mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" - ln -sf "$HOME/\(daemonRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" - cat > "$HOME/.cmux/bin/cmux" <<'CMUX_REMOTE_WRAPPER' - #!/bin/sh - set -eu - - if [ -n "${CMUX_SOCKET_PATH:-}" ]; then - _cmux_port="${CMUX_SOCKET_PATH##*:}" - case "$_cmux_port" in - ''|*[!0-9]*) - ;; - *) - _cmux_map="$HOME/.cmux/relay/${_cmux_port}.daemon_path" - if [ -r "$_cmux_map" ]; then - _cmux_daemon="$(cat "$_cmux_map" 2>/dev/null || true)" - if [ -n "$_cmux_daemon" ] && [ -x "$_cmux_daemon" ]; then - exec "$_cmux_daemon" cli "$@" - fi - fi - ;; - esac - fi - - if [ -x "$HOME/.cmux/bin/cmuxd-remote-current" ]; then - exec "$HOME/.cmux/bin/cmuxd-remote-current" cli "$@" - fi - - echo "cmux: remote daemon not installed; reconnect from local cmux." >&2 - exit 127 - CMUX_REMOTE_WRAPPER - chmod 755 "$HOME/.cmux/bin/cmux" - ln -sf "$HOME/.cmux/bin/cmux" /usr/local/bin/cmux 2>/dev/null \ - || sudo -n ln -sf "$HOME/.cmux/bin/cmux" /usr/local/bin/cmux 2>/dev/null \ - || true - """ - let command = "sh -lc \(Self.shellSingleQuoted(script))" - do { - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - if result.status != 0 { - NSLog("[cmux] warning: failed to create remote CLI symlink (exit %d): %@", - result.status, - Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error") - } - } catch { - NSLog("[cmux] warning: failed to create remote CLI symlink: %@", error.localizedDescription) - } - } - - /// Writes `~/.cmux/socket_addr` on the remote with the relay TCP address. - /// The Go CLI relay reads this file as a fallback when CMUX_SOCKET_PATH is not set. - private func writeRemoteSocketAddrLocked() { - guard let relayPort = configuration.relayPort, relayPort > 0 else { return } - let addr = "127.0.0.1:\(relayPort)" - let script = "mkdir -p \"$HOME/.cmux\" && printf '%s' '\(addr)' > \"$HOME/.cmux/socket_addr\"" - let command = "sh -lc \(Self.shellSingleQuoted(script))" - do { - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - if result.status != 0 { - NSLog("[cmux] warning: failed to write remote socket_addr (exit %d): %@", - result.status, - Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error") - } - } catch { - NSLog("[cmux] warning: failed to write remote socket_addr: %@", error.localizedDescription) - } - } - - /// Writes relay-port -> daemon binary mapping used by the remote `cmux` wrapper. - /// This keeps CLI dispatch stable when multiple local cmux versions target the same host. - private func writeRemoteRelayDaemonMappingLocked() { - guard let relayPort = configuration.relayPort, relayPort > 0, - let daemonRemotePath, !daemonRemotePath.isEmpty else { return } - let script = """ - mkdir -p "$HOME/.cmux/relay" && \ - printf '%s' "$HOME/\(daemonRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" - """ - let command = "sh -lc \(Self.shellSingleQuoted(script))" - do { - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - if result.status != 0 { - NSLog("[cmux] warning: failed to write remote relay daemon mapping (exit %d): %@", - result.status, - Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "unknown error") - } - } catch { - NSLog("[cmux] warning: failed to write remote relay daemon mapping: %@", error.localizedDescription) - } - } - - private func resolveRemotePlatformLocked() throws -> RemotePlatform { - let script = "uname -s; uname -m" - let command = "sh -lc \(Self.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 10) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "cmux.remote.daemon", code: 10, userInfo: [ - NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", - ]) - } - - let lines = result.stdout - .split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - guard lines.count >= 2 else { - throw NSError(domain: "cmux.remote.daemon", code: 11, userInfo: [ - NSLocalizedDescriptionKey: "remote platform probe returned invalid output", - ]) - } - - guard let goOS = Self.mapUnameOS(lines[0]), - let goArch = Self.mapUnameArch(lines[1]) else { - throw NSError(domain: "cmux.remote.daemon", code: 12, userInfo: [ - NSLocalizedDescriptionKey: "unsupported remote platform \(lines[0])/\(lines[1])", - ]) - } - - return RemotePlatform(goOS: goOS, goArch: goArch) - } - - private func remoteDaemonExistsLocked(remotePath: String) throws -> Bool { - let script = "if [ -x \(Self.shellSingleQuoted(remotePath)) ]; then echo yes; else echo no; fi" - let command = "sh -lc \(Self.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) - guard result.status == 0 else { return false } - return result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) == "yes" - } - - private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { - guard let repoRoot = Self.findRepoRoot() else { - throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ - NSLocalizedDescriptionKey: "cannot locate cmux repo root for daemon build", - ]) - } - let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) - let goModPath = daemonRoot.appendingPathComponent("go.mod").path - guard FileManager.default.fileExists(atPath: goModPath) else { - throw NSError(domain: "cmux.remote.daemon", code: 21, userInfo: [ - NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)", - ]) - } - guard let goBinary = Self.which("go") else { - throw NSError(domain: "cmux.remote.daemon", code: 22, userInfo: [ - NSLocalizedDescriptionKey: "go is required to build cmuxd-remote", - ]) - } - - let cacheRoot = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - .appendingPathComponent("cmux-remote-daemon-build", isDirectory: true) - .appendingPathComponent(version, isDirectory: true) - .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) - try FileManager.default.createDirectory(at: cacheRoot, withIntermediateDirectories: true) - let output = cacheRoot.appendingPathComponent("cmuxd-remote", isDirectory: false) - - var env = ProcessInfo.processInfo.environment - env["GOOS"] = goOS - env["GOARCH"] = goArch - env["CGO_ENABLED"] = "0" - let ldflags = "-s -w -X main.version=\(version)" - let result = try runProcess( - executable: goBinary, - arguments: ["build", "-trimpath", "-ldflags", ldflags, "-o", output.path, "./cmd/cmuxd-remote"], - environment: env, - currentDirectory: daemonRoot, - stdin: nil, - timeout: 90 - ) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "go build failed with status \(result.status)" - throw NSError(domain: "cmux.remote.daemon", code: 23, userInfo: [ - NSLocalizedDescriptionKey: "failed to build cmuxd-remote: \(detail)", - ]) - } - guard FileManager.default.isExecutableFile(atPath: output.path) else { - throw NSError(domain: "cmux.remote.daemon", code: 24, userInfo: [ - NSLocalizedDescriptionKey: "cmuxd-remote build output is not executable", - ]) - } - return output - } - - private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { - let remoteDirectory = (remotePath as NSString).deletingLastPathComponent - let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" - - let mkdirScript = "mkdir -p \(Self.shellSingleQuoted(remoteDirectory))" - let mkdirCommand = "sh -lc \(Self.shellSingleQuoted(mkdirScript))" - let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12) - guard mkdirResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)" - throw NSError(domain: "cmux.remote.daemon", code: 30, userInfo: [ - NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)", - ]) - } - - var scpArgs: [String] = ["-q", "-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 configuration.sshOptions { - let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - scpArgs += ["-o", trimmed] - } - scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"] - 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 NSError(domain: "cmux.remote.daemon", code: 31, userInfo: [ - NSLocalizedDescriptionKey: "failed to upload cmuxd-remote: \(detail)", - ]) - } - - let finalizeScript = """ - chmod 755 \(Self.shellSingleQuoted(remoteTempPath)) && \ - mv \(Self.shellSingleQuoted(remoteTempPath)) \(Self.shellSingleQuoted(remotePath)) - """ - let finalizeCommand = "sh -lc \(Self.shellSingleQuoted(finalizeScript))" - let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12) - guard finalizeResult.status == 0 else { - let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)" - throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [ - NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", - ]) - } - } - - 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" - let command = "sh -lc \(Self.shellSingleQuoted(script))" - let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12) - guard result.status == 0 else { - let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" - throw NSError(domain: "cmux.remote.daemon", code: 40, userInfo: [ - NSLocalizedDescriptionKey: "failed to start remote daemon: \(detail)", - ]) - } - - let responseLine = result.stdout - .split(separator: "\n") - .map(String.init) - .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? "" - guard !responseLine.isEmpty, - let data = responseLine.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { - throw NSError(domain: "cmux.remote.daemon", code: 41, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon hello returned invalid JSON", - ]) - } - - if let ok = payload["ok"] as? Bool, !ok { - let errorMessage: String = { - if let errorObject = payload["error"] as? [String: Any], - let message = errorObject["message"] as? String, - !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - return message - } - return "hello call failed" - }() - throw NSError(domain: "cmux.remote.daemon", code: 42, userInfo: [ - NSLocalizedDescriptionKey: "remote daemon hello failed: \(errorMessage)", - ]) - } - - let resultObject = payload["result"] as? [String: Any] ?? [:] - let name = (resultObject["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let version = (resultObject["version"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let capabilities = (resultObject["capabilities"] as? [String]) ?? [] - return DaemonHello( - name: (name?.isEmpty == false ? name! : "cmuxd-remote"), - version: (version?.isEmpty == false ? version! : "dev"), - capabilities: capabilities, - remotePath: remotePath - ) - } - - private static func parseRemotePorts(line: String) -> [Int] { - let tokens = line.split(whereSeparator: \.isWhitespace) - let values = tokens.compactMap { Int($0) } - let filtered = values.filter { $0 >= 1024 && $0 <= 65535 } - let unique = Set(filtered) - if unique.count <= 40 { - return unique.sorted() - } - return Array(unique.sorted().prefix(40)) - } - - private static func probeScript() -> String { - """ - set -eu - # Force an initial emission so the controller can transition out of - # "connecting" even when no ports are detected. - CMUX_LAST="__cmux_init__" - while true; do - if command -v ss >/dev/null 2>&1; then - PORTS="$(ss -ltnH 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | sort -n -u | tr '\\n' ' ')" - elif command -v netstat >/dev/null 2>&1; then - PORTS="$(netstat -lnt 2>/dev/null | awk '{print $4}' | sed -E 's/.*:([0-9]+)$/\\1/' | awk '/^[0-9]+$/ {print $1}' | sort -n -u | tr '\\n' ' ')" - else - PORTS="" - fi - if [ "$PORTS" != "$CMUX_LAST" ]; then - echo "$PORTS" - CMUX_LAST="$PORTS" - fi - sleep 2 - done - """ - } - - private static func shellSingleQuoted(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" - } - - private static func mapUnameOS(_ raw: String) -> String? { - switch raw.lowercased() { - case "linux": - return "linux" - case "darwin": - return "darwin" - case "freebsd": - return "freebsd" - default: - return nil - } - } - - private static func mapUnameArch(_ raw: String) -> String? { - switch raw.lowercased() { - case "x86_64", "amd64": - return "amd64" - case "aarch64", "arm64": - return "arm64" - case "armv7l": - return "arm" - default: - return nil - } - } - - private static func remoteDaemonVersion() -> String { - let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let bundleVersion, !bundleVersion.isEmpty { - return bundleVersion - } - return "dev" - } - - private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { - ".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote" - } - - private static func which(_ executable: String) -> String? { - let path = ProcessInfo.processInfo.environment["PATH"] ?? "" - for component in path.split(separator: ":") { - let candidate = String(component) + "/" + executable - if FileManager.default.isExecutableFile(atPath: candidate) { - return candidate - } - } - return nil - } - - private static func findRepoRoot() -> URL? { - var candidates: [URL] = [] - let compileTimeRoot = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // Sources - .deletingLastPathComponent() // repo root - candidates.append(compileTimeRoot) - let environment = ProcessInfo.processInfo.environment - if let envRoot = environment["CMUX_REMOTE_DAEMON_SOURCE_ROOT"], - !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) - } - if let envRoot = environment["CMUXTERM_REPO_ROOT"], - !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) - } - candidates.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) - if let executable = Bundle.main.executableURL?.deletingLastPathComponent() { - candidates.append(executable) - candidates.append(executable.deletingLastPathComponent()) - candidates.append(executable.deletingLastPathComponent().deletingLastPathComponent()) - } - - let fm = FileManager.default - for base in candidates { - var cursor = base.standardizedFileURL - for _ in 0..<10 { - let marker = cursor.appendingPathComponent("daemon/remote/go.mod").path - if fm.fileExists(atPath: marker) { - return cursor - } - let parent = cursor.deletingLastPathComponent() - if parent.path == cursor.path { - break - } - cursor = parent - } - } - return nil - } - - private static func bestErrorLine(stderr: String, stdout: String = "") -> String? { - if let stderrLine = meaningfulErrorLine(in: stderr) { - return stderrLine - } - if let stdoutLine = meaningfulErrorLine(in: stdout) { - return stdoutLine - } - return nil - } - - private static func meaningfulErrorLine(in text: String) -> String? { - let lines = text - .split(separator: "\n") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - - for line in lines.reversed() where !isNoiseLine(line) { - return line - } - return lines.last - } - - private static func isNoiseLine(_ line: String) -> Bool { - let lowered = line.lowercased() - if lowered.hasPrefix("warning: permanently added") { return true } - if lowered.hasPrefix("debug") { return true } - if lowered.hasPrefix("transferred:") { return true } - if lowered.hasPrefix("openbsd_") { return true } - if lowered.contains("pseudo-terminal will not be allocated") { return true } - return false - } - - private static func retrySuffix(retry: Int, delay: TimeInterval) -> String { - let seconds = max(1, Int(delay.rounded())) - return " (retry \(retry) in \(seconds)s)" - } - - /// Kills orphaned SSH relay processes from previous app sessions. - /// These processes survive app restarts because `pkill` doesn't trigger graceful cleanup. - private static func killOrphanedRelayProcesses(relayPort: Int, socketPath: String, destination: String) { - guard relayPort > 0 else { return } - let pipe = Pipe() - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") - let socketPathPattern = NSRegularExpression.escapedPattern(for: socketPath) - let destinationPattern = NSRegularExpression.escapedPattern(for: destination) - let relayPattern = "ssh.*-R[[:space:]]*127\\.0\\.0\\.1:\(relayPort):\(socketPathPattern).*\(destinationPattern)" - process.arguments = ["-f", relayPattern] - process.standardOutput = pipe - process.standardError = pipe - do { - try process.run() - process.waitUntilExit() - } catch { - // Best-effort cleanup; ignore failures - } - } - - private static func isLoopbackPortAvailable(port: Int) -> Bool { - guard port > 0 && port <= 65535 else { return false } - - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { close(fd) } - - var yes: Int32 = 1 - setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout.size)) - - var addr = sockaddr_in() - addr.sin_len = UInt8(MemoryLayout.size) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = in_port_t(UInt16(port).bigEndian) - addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - - let bindResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - bind(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - return bindResult == 0 - } - - /// Check if a port on 127.0.0.1 is already accepting connections (e.g. forwarded by another workspace). - private static func isLoopbackPortReachable(port: Int) -> Bool { - guard port > 0 && port <= 65535 else { return false } - - let fd = socket(AF_INET, SOCK_STREAM, 0) - guard fd >= 0 else { return false } - defer { close(fd) } - - var addr = sockaddr_in() - addr.sin_len = UInt8(MemoryLayout.size) - addr.sin_family = sa_family_t(AF_INET) - addr.sin_port = in_port_t(UInt16(port).bigEndian) - addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) - - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - return result == 0 - } -} - -enum SidebarLogLevel: String { - case info - case progress - case success - case warning - case error -} - -struct SidebarLogEntry { - let message: String - let level: SidebarLogLevel - let source: String? - let timestamp: Date -} - -struct SidebarProgressState { - let value: Double - let label: String? -} - -struct SidebarGitBranchState { - let branch: String - let isDirty: Bool -} - -enum SidebarPullRequestStatus: String { - case open - case merged - case closed -} - -struct SidebarPullRequestState: Equatable { - let number: Int - let label: String - let url: URL - let status: SidebarPullRequestStatus -} - -enum SidebarBranchOrdering { - struct BranchEntry: Equatable { - let name: String - let isDirty: Bool - } - - struct BranchDirectoryEntry: Equatable { - let branch: String? - let isDirty: Bool - let directory: String? - } - - static func orderedPaneIds(tree: ExternalTreeNode) -> [String] { - switch tree { - case .pane(let pane): - return [pane.id] - case .split(let split): - return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second) - } - } - - static func orderedPanelIds( - tree: ExternalTreeNode, - paneTabs: [String: [UUID]], - fallbackPanelIds: [UUID] - ) -> [UUID] { - var ordered: [UUID] = [] - var seen: Set = [] - - for paneId in orderedPaneIds(tree: tree) { - for panelId in paneTabs[paneId] ?? [] { - if seen.insert(panelId).inserted { - ordered.append(panelId) - } - } - } - - for panelId in fallbackPanelIds { - if seen.insert(panelId).inserted { - ordered.append(panelId) - } - } - - return ordered - } - - static func orderedUniqueBranches( - orderedPanelIds: [UUID], - panelBranches: [UUID: SidebarGitBranchState], - fallbackBranch: SidebarGitBranchState? - ) -> [BranchEntry] { - var orderedNames: [String] = [] - var branchDirty: [String: Bool] = [:] - - for panelId in orderedPanelIds { - guard let state = panelBranches[panelId] else { continue } - let name = state.branch.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { continue } - - if branchDirty[name] == nil { - orderedNames.append(name) - branchDirty[name] = state.isDirty - } else if state.isDirty { - branchDirty[name] = true - } - } - - if orderedNames.isEmpty, let fallbackBranch { - let name = fallbackBranch.branch.trimmingCharacters(in: .whitespacesAndNewlines) - if !name.isEmpty { - return [BranchEntry(name: name, isDirty: fallbackBranch.isDirty)] - } - } - - return orderedNames.map { name in - BranchEntry(name: name, isDirty: branchDirty[name] ?? false) - } - } - - static func orderedUniquePullRequests( - orderedPanelIds: [UUID], - panelPullRequests: [UUID: SidebarPullRequestState], - fallbackPullRequest: SidebarPullRequestState? - ) -> [SidebarPullRequestState] { - func statusPriority(_ status: SidebarPullRequestStatus) -> Int { - switch status { - case .merged: return 3 - case .open: return 2 - case .closed: return 1 - } - } - - func normalizedReviewURLKey(for url: URL) -> String { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url.absoluteString - } - components.query = nil - components.fragment = nil - let scheme = components.scheme?.lowercased() ?? "" - let host = components.host?.lowercased() ?? "" - let port = components.port.map { ":\($0)" } ?? "" - var path = components.path - if path.hasSuffix("/"), path.count > 1 { - path.removeLast() - } - return "\(scheme)://\(host)\(port)\(path)" - } - - func reviewKey(for state: SidebarPullRequestState) -> String { - "\(state.label.lowercased())#\(state.number)|\(normalizedReviewURLKey(for: state.url))" - } - - var orderedKeys: [String] = [] - var pullRequestsByKey: [String: SidebarPullRequestState] = [:] - - for panelId in orderedPanelIds { - guard let state = panelPullRequests[panelId] else { continue } - let key = reviewKey(for: state) - if pullRequestsByKey[key] == nil { - orderedKeys.append(key) - pullRequestsByKey[key] = state - continue - } - guard let existing = pullRequestsByKey[key] else { continue } - if statusPriority(state.status) > statusPriority(existing.status) { - pullRequestsByKey[key] = state - } - } - - if orderedKeys.isEmpty, let fallbackPullRequest { - return [fallbackPullRequest] - } - - return orderedKeys.compactMap { pullRequestsByKey[$0] } - } - - static func orderedUniqueBranchDirectoryEntries( - orderedPanelIds: [UUID], - panelBranches: [UUID: SidebarGitBranchState], - panelDirectories: [UUID: String], - defaultDirectory: String?, - fallbackBranch: SidebarGitBranchState? - ) -> [BranchDirectoryEntry] { - struct EntryKey: Hashable { - let directory: String? - let branch: String? - } - - struct MutableEntry { - var branch: String? - var isDirty: Bool - var directory: String? - } - - 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 - } - let defaultBranchForPanels = shouldUseFallbackBranchPerPanel ? normalizedFallbackBranch : nil - let defaultBranchDirty = shouldUseFallbackBranchPerPanel ? (fallbackBranch?.isDirty ?? false) : false - - var order: [EntryKey] = [] - var entries: [EntryKey: MutableEntry] = [:] - - for panelId in orderedPanelIds { - let panelBranch = normalized(panelBranches[panelId]?.branch) - let branch = panelBranch ?? defaultBranchForPanels - let directory = normalized(panelDirectories[panelId] ?? defaultDirectory) - guard branch != nil || directory != nil else { continue } - - let panelDirty = panelBranch != nil - ? (panelBranches[panelId]?.isDirty ?? false) - : defaultBranchDirty - - let key: EntryKey - if let directoryKey = canonicalDirectoryKey(directory) { - key = EntryKey(directory: directoryKey, branch: nil) - } else { - key = EntryKey(directory: nil, branch: branch) - } - - if entries[key] == nil { - order.append(key) - entries[key] = MutableEntry( - branch: branch, - isDirty: panelDirty, - directory: directory - ) - } else { - if panelDirty { - entries[key]?.isDirty = true - } - if let branch { - entries[key]?.branch = branch - } - if let directory { - entries[key]?.directory = directory - } - } - } - - if order.isEmpty, let fallbackBranch { - let branch = normalized(fallbackBranch.branch) - let directory = normalized(defaultDirectory) - if branch != nil || directory != nil { - return [BranchDirectoryEntry( - branch: branch, - isDirty: fallbackBranch.isDirty, - directory: directory - )] - } - } - - return order.compactMap { key in - guard let entry = entries[key] else { return nil } - return BranchDirectoryEntry( - branch: entry.branch, - isDirty: entry.isDirty, - directory: entry.directory - ) - } - } -} - -enum WorkspaceRemoteConnectionState: String { - case disconnected - case connecting - case connected - case error -} - -enum WorkspaceRemoteDaemonState: String { - case unavailable - case bootstrapping - case ready - case error -} - -struct WorkspaceRemoteDaemonStatus: Equatable { - var state: WorkspaceRemoteDaemonState = .unavailable - var detail: String? - var version: String? - var name: String? - var capabilities: [String] = [] - var remotePath: String? - - func payload() -> [String: Any] { - [ - "state": state.rawValue, - "detail": detail ?? NSNull(), - "version": version ?? NSNull(), - "name": name ?? NSNull(), - "capabilities": capabilities, - "remote_path": remotePath ?? NSNull(), - ] - } -} - -struct WorkspaceRemoteConfiguration: Equatable { - let destination: String - let port: Int? - let identityFile: String? - let sshOptions: [String] - let localProxyPort: Int? - let relayPort: Int? - let localSocketPath: String? - - var displayTarget: String { - guard let port else { return destination } - return "\(destination):\(port)" - } -} - -struct ClosedBrowserPanelRestoreSnapshot { - let workspaceId: UUID - let url: URL? - let originalPaneId: UUID - let originalTabIndex: Int - let fallbackSplitOrientation: SplitOrientation? - let fallbackSplitInsertFirst: Bool - let fallbackAnchorPaneId: UUID? -} - -/// Workspace represents a sidebar tab. -/// Each workspace contains one BonsplitController that manages split panes and nested surfaces. -@MainActor -final class Workspace: Identifiable, ObservableObject { - let id: UUID - @Published var title: String - @Published var customTitle: String? - @Published var isPinned: Bool = false - @Published var customColor: String? - @Published var currentDirectory: String - - /// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session) - var portOrdinal: Int = 0 - - /// The bonsplit controller managing the split panes for this workspace - let bonsplitController: BonsplitController - - /// Mapping from bonsplit TabID to our Panel instances - @Published private(set) var panels: [UUID: any Panel] = [:] - - /// Subscriptions for panel updates (e.g., browser title changes) - private var panelSubscriptions: [UUID: AnyCancellable] = [:] - - /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) - private var isProgrammaticSplit = false - var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? - - nonisolated static func resolvedSnapshotTerminalScrollback( - capturedScrollback: String?, - fallbackScrollback: String? - ) -> String? { - if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { - return captured - } - return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) - } - +extension Workspace { func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { let tree = bonsplitController.treeSnapshot() let layout = sessionLayoutSnapshot(from: tree) @@ -1891,10 +312,17 @@ final class Workspace: Identifiable, ObservableObject { let markdownSnapshot: SessionMarkdownPanelSnapshot? switch panel.panelType { case .terminal: - guard let _ = panel as? TerminalPanel else { return nil } + guard let terminalPanel = panel as? TerminalPanel else { return nil } + let capturedScrollback = includeScrollback + ? TerminalController.shared.readTerminalTextForSessionSnapshot( + terminalPanel: terminalPanel, + includeScrollback: true, + lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal + ) + : nil let resolvedScrollback = terminalSnapshotScrollback( panelId: panelId, - capturedScrollback: nil, + capturedScrollback: capturedScrollback, includeScrollback: includeScrollback ) terminalSnapshot = SessionTerminalPanelSnapshot( @@ -1910,17 +338,17 @@ final class Workspace: Identifiable, ObservableObject { browserSnapshot = SessionBrowserPanelSnapshot( urlString: browserPanel.preferredURLStringForOmnibar(), shouldRenderWebView: browserPanel.shouldRenderWebView, - pageZoom: Double(browserPanel.webView.pageZoom), + pageZoom: Double(browserPanel.currentPageZoomFactor()), developerToolsVisible: browserPanel.isDeveloperToolsVisible(), backHistoryURLStrings: historySnapshot.backHistoryURLStrings, forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings ) markdownSnapshot = nil case .markdown: - guard let mdPanel = panel as? MarkdownPanel else { return nil } + guard let markdownPanel = panel as? MarkdownPanel else { return nil } terminalSnapshot = nil browserSnapshot = nil - markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath) + markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) } return SessionPanelSnapshot( @@ -1940,6 +368,16 @@ final class Workspace: Identifiable, ObservableObject { ) } + nonisolated static func resolvedSnapshotTerminalScrollback( + capturedScrollback: String?, + fallbackScrollback: String? + ) -> String? { + if let captured = SessionPersistencePolicy.truncatedScrollback(capturedScrollback) { + return captured + } + return SessionPersistencePolicy.truncatedScrollback(fallbackScrollback) + } + private func terminalSnapshotScrollback( panelId: UUID, capturedScrollback: String?, @@ -2087,18 +525,7 @@ final class Workspace: Identifiable, ObservableObject { applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) return browserPanel.id case .markdown: - guard let filePath = snapshot.markdown?.filePath else { - return nil - } - guard let markdownPanel = newMarkdownSurface( - inPane: paneId, - filePath: filePath, - focus: false - ) else { - return nil - } - applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) - return markdownPanel.id + return nil } } @@ -2144,7 +571,7 @@ final class Workspace: Identifiable, ObservableObject { let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) if pageZoom.isFinite { - browserPanel.webView.pageZoom = pageZoom + _ = browserPanel.setPageZoomFactor(pageZoom) } if browserSnapshot.developerToolsVisible { @@ -2559,10 +986,9 @@ private final class WorkspaceRemoteDaemonRPCClient { return sshCommonArguments(configuration: configuration, batchMode: true) + [configuration.destination, command] } - private static let connectionSharingOptionKeys: Set = [ + private static let batchSSHControlOptionKeys: Set = [ "controlmaster", "controlpersist", - "controlpath", ] private static func sshCommonArguments(configuration: WorkspaceRemoteConfiguration, batchMode: Bool) -> [String] { @@ -2582,7 +1008,8 @@ private final class WorkspaceRemoteDaemonRPCClient { } if batchMode { args += ["-o", "BatchMode=yes"] - // Avoid shared ControlPath lock contention with interactive ssh sessions. + // Batch helpers should reuse an existing ControlPath if one was configured, + // but must never try to negotiate a new master connection. args += ["-o", "ControlMaster=no"] } if let port = configuration.port { @@ -2620,7 +1047,7 @@ private final class WorkspaceRemoteDaemonRPCClient { private static func backgroundSSHOptions(_ options: [String]) -> [String] { normalizedSSHOptions(options).filter { option in guard let key = sshOptionKey(option) else { return false } - return !connectionSharingOptionKeys.contains(key) + return !batchSSHControlOptionKeys.contains(key) } } @@ -3852,7 +2279,6 @@ private final class WorkspaceRemoteSessionController { } if batchMode { args += ["-o", "BatchMode=yes"] - // Avoid shared ControlPath lock contention with interactive ssh sessions. args += ["-o", "ControlMaster=no"] } if let port = configuration.port { @@ -3888,14 +2314,13 @@ private final class WorkspaceRemoteSessionController { } private func backgroundSSHOptions(_ options: [String]) -> [String] { - let sharingKeys: Set = [ + let batchSSHControlOptionKeys: Set = [ "controlmaster", "controlpersist", - "controlpath", ] return normalizedSSHOptions(options).filter { option in guard let key = sshOptionKey(option) else { return false } - return !sharingKeys.contains(key) + return !batchSSHControlOptionKeys.contains(key) } } @@ -4185,7 +2610,6 @@ private final class WorkspaceRemoteSessionController { if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] } - // Keep bootstrap SCP detached from shared interactive ssh control sockets. scpArgs += ["-o", "ControlMaster=no"] if let port = configuration.port { scpArgs += ["-P", String(port)] @@ -4524,6 +2948,8 @@ struct WorkspaceRemoteConfiguration: Equatable { let identityFile: String? let sshOptions: [String] let localProxyPort: Int? + let relayPort: Int? + let localSocketPath: String? var displayTarget: String { guard let port else { return destination } @@ -4861,18 +3287,26 @@ final class Workspace: Identifiable, ObservableObject { return panel } + enum FocusPanelTrigger { + case standard + case terminalFirstResponder + } + /// Published directory for each panel @Published var panelDirectories: [UUID: String] = [:] @Published var panelTitles: [UUID: String] = [:] @Published private(set) var panelCustomTitles: [UUID: String] = [:] @Published private(set) var pinnedPanelIds: Set = [] @Published private(set) var manualUnreadPanelIds: Set = [] - @Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:] + private var manualUnreadMarkedAt: [UUID: Date] = [:] + nonisolated private static let manualUnreadFocusGraceInterval: TimeInterval = 0.2 + nonisolated private static let manualUnreadClearDelayAfterFocusFlash: TimeInterval = 0.2 @Published var statusEntries: [String: SidebarStatusEntry] = [:] @Published var metadataBlocks: [String: SidebarMetadataBlock] = [:] @Published var logEntries: [SidebarLogEntry] = [] @Published var progress: SidebarProgressState? @Published var gitBranch: SidebarGitBranchState? + @Published var panelGitBranches: [UUID: SidebarGitBranchState] = [:] @Published var pullRequest: SidebarPullRequestState? @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] @@ -4930,27 +3364,15 @@ final class Workspace: Identifiable, ObservableObject { private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { BonsplitConfiguration.SplitButtonTooltips( - newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip(String(localized: "workspace.tooltip.newTerminal", defaultValue: "New Terminal")), - newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip(String(localized: "workspace.tooltip.newBrowser", defaultValue: "New Browser")), - splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip(String(localized: "workspace.tooltip.splitRight", defaultValue: "Split Right")), - splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip(String(localized: "workspace.tooltip.splitDown", defaultValue: "Split Down")) + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") ) } private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { - bonsplitAppearance( - from: config.backgroundColor, - backgroundOpacity: config.backgroundOpacity - ) - } - - static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String { - let themedColor = GhosttyBackgroundTheme.color( - backgroundColor: backgroundColor, - opacity: backgroundOpacity - ) - let includeAlpha = themedColor.alphaComponent < 0.999 - return themedColor.hexString(includeAlpha: includeAlpha) + bonsplitAppearance(from: config.backgroundColor) } nonisolated static func resolvedChromeColors( @@ -4959,49 +3381,37 @@ final class Workspace: Identifiable, ObservableObject { .init(backgroundHex: backgroundColor.hexString()) } - private static func bonsplitAppearance( - from backgroundColor: NSColor, - backgroundOpacity: Double - ) -> BonsplitConfiguration.Appearance { - BonsplitConfiguration.Appearance( + private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { + let chromeColors = resolvedChromeColors(from: backgroundColor) + return BonsplitConfiguration.Appearance( splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, - chromeColors: .init( - backgroundHex: Self.bonsplitChromeHex( - backgroundColor: backgroundColor, - backgroundOpacity: backgroundOpacity - ) - ) + chromeColors: chromeColors ) } func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { - applyGhosttyChrome( - backgroundColor: config.backgroundColor, - backgroundOpacity: config.backgroundOpacity, - reason: reason - ) + applyGhosttyChrome(backgroundColor: config.backgroundColor, reason: reason) } - func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { - let nextHex = Self.bonsplitChromeHex( - backgroundColor: backgroundColor, - backgroundOpacity: backgroundOpacity - ) + func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { let currentChromeColors = bonsplitController.configuration.appearance.chromeColors - let isNoOp = currentChromeColors.backgroundHex == nextHex + let nextChromeColors = Self.resolvedChromeColors(from: backgroundColor) + let isNoOp = currentChromeColors.backgroundHex == nextChromeColors.backgroundHex && + currentChromeColors.borderHex == nextChromeColors.borderHex if GhosttyApp.shared.backgroundLogEnabled { let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil" + let nextBackgroundHex = nextChromeColors.backgroundHex ?? "nil" GhosttyApp.shared.logBackground( - "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)" + "theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextBackgroundHex) currentBorder=\(currentChromeColors.borderHex ?? "nil") nextBorder=\(nextChromeColors.borderHex ?? "nil") noop=\(isNoOp)" ) } if isNoOp { return } - bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex + bonsplitController.configuration.appearance.chromeColors = nextChromeColors if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" @@ -5009,14 +3419,6 @@ final class Workspace: Identifiable, ObservableObject { } } - func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { - applyGhosttyChrome( - backgroundColor: backgroundColor, - backgroundOpacity: backgroundColor.alphaComponent, - reason: reason - ) - } - init( title: String = "Terminal", workingDirectory: String? = nil, @@ -5030,7 +3432,6 @@ final class Workspace: Identifiable, ObservableObject { self.processTitle = title self.title = title self.customTitle = nil - self.customColor = nil let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty @@ -5042,10 +3443,7 @@ final class Workspace: Identifiable, ObservableObject { // and keep split entry instantaneous. // Avoid re-reading/parsing Ghostty config on every new workspace; this hot path // runs for socket/CLI workspace creation and can cause visible typing lag. - let appearance = Self.bonsplitAppearance( - from: GhosttyApp.shared.defaultBackgroundColor, - backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity - ) + let appearance = Self.bonsplitAppearance(from: GhosttyApp.shared.defaultBackgroundColor) let config = BonsplitConfiguration( allowSplits: true, allowCloseTabs: true, @@ -5067,6 +3465,7 @@ final class Workspace: Identifiable, ObservableObject { let terminalPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, portOrdinal: portOrdinal, initialCommand: initialTerminalCommand, @@ -5074,6 +3473,7 @@ final class Workspace: Identifiable, ObservableObject { ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) // Create initial tab in bonsplit and store the mapping var initialTabId: TabID? @@ -5093,6 +3493,10 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.closeTab(welcomeTabId) } + bonsplitController.onExternalTabDrop = { [weak self] request in + self?.handleExternalTabDrop(request) ?? false + } + // Set ourselves as delegate bonsplitController.delegate = self @@ -5122,8 +3526,10 @@ final class Workspace: Identifiable, ObservableObject { } func refreshSplitButtonTooltips() { + let tooltips = Self.currentSplitButtonTooltips() var configuration = bonsplitController.configuration - configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips() + guard configuration.appearance.splitButtonTooltips != tooltips else { return } + configuration.appearance.splitButtonTooltips = tooltips bonsplitController.configuration = configuration } @@ -5144,6 +3550,9 @@ final class Workspace: Identifiable, ObservableObject { /// Deterministic tab selection to apply after a tab closes. /// Keyed by the closing tab ID, value is the tab ID we want to select next. private var postCloseSelectTabId: [TabID: TabID] = [:] + /// Panel IDs that were in a pane when a pane-close operation was approved. + /// Bonsplit pane-close does not emit per-tab didClose callbacks. + private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:] private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:] private var isApplyingTabSelection = false private var pendingTabSelection: (tabId: TabID, pane: PaneID)? @@ -5151,8 +3560,11 @@ final class Workspace: Identifiable, ObservableObject { private var focusReconcileScheduled = false #if DEBUG private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0 + private var debugLastDidMoveTabTimestamp: TimeInterval = 0 + private var debugDidMoveTabEventCount: UInt64 = 0 #endif private var geometryReconcileScheduled = false + private var geometryReconcileNeedsRerun = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 @@ -5180,6 +3592,15 @@ final class Workspace: Identifiable, ObservableObject { private var detachingTabIds: Set = [] private var pendingDetachedSurfaces: [TabID: DetachedSurfaceTransfer] = [:] + private var activeDetachCloseTransactions: Int = 0 + private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 } + +#if DEBUG + private func debugElapsedMs(since start: TimeInterval) -> String { + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + return String(format: "%.2f", ms) + } +#endif func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? { surfaceIdToPanelId[surfaceId] @@ -5224,6 +3645,30 @@ final class Workspace: Identifiable, ObservableObject { panelSubscriptions[browserPanel.id] = subscription } + private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) { + let subscription = markdownPanel.$displayTitle + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self, weak markdownPanel] newTitle in + guard let self, + let markdownPanel, + let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return } + guard let existing = self.bonsplitController.tab(tabId) else { return } + + if self.panelTitles[markdownPanel.id] != newTitle { + self.panelTitles[markdownPanel.id] = newTitle + } + let resolvedTitle = self.resolvedPanelTitle(panelId: markdownPanel.id, fallback: newTitle) + guard existing.title != resolvedTitle else { return } + self.bonsplitController.updateTab( + tabId, + title: resolvedTitle, + hasCustomTitle: self.panelCustomTitles[markdownPanel.id] != nil + ) + } + panelSubscriptions[markdownPanel.id] = subscription + } + private func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? { guard let target = remoteDisplayTarget else { return nil } return BrowserRemoteWorkspaceStatus( @@ -5242,30 +3687,6 @@ final class Workspace: Identifiable, ObservableObject { } } - private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) { - let subscription = markdownPanel.$displayTitle - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self, weak markdownPanel] newTitle in - guard let self = self, - let markdownPanel = markdownPanel, - let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return } - guard let existing = self.bonsplitController.tab(tabId) else { return } - - if self.panelTitles[markdownPanel.id] != newTitle { - self.panelTitles[markdownPanel.id] = newTitle - } - let resolvedTitle = self.resolvedPanelTitle(panelId: markdownPanel.id, fallback: newTitle) - guard existing.title != resolvedTitle else { return } - self.bonsplitController.updateTab( - tabId, - title: resolvedTitle, - hasCustomTitle: self.panelCustomTitles[markdownPanel.id] != nil - ) - } - panelSubscriptions[markdownPanel.id] = subscription - } - // MARK: - Panel Access func panel(for surfaceId: TabID) -> (any Panel)? { @@ -5325,17 +3746,16 @@ final class Workspace: Identifiable, ObservableObject { private func syncUnreadBadgeStateForPanel(_ panelId: UUID) { guard let tabId = surfaceIdFromPanelId(panelId) else { return } - let shouldShowUnread = manualUnreadPanelIds.contains(panelId) || hasUnreadNotification(panelId: panelId) + let shouldShowUnread = Self.shouldShowUnreadIndicator( + hasUnreadNotification: hasUnreadNotification(panelId: panelId), + isManuallyUnread: manualUnreadPanelIds.contains(panelId) + ) if let existing = bonsplitController.tab(tabId), existing.showsNotificationBadge == shouldShowUnread { return } bonsplitController.updateTab(tabId, showsNotificationBadge: shouldShowUnread) } - static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool { - hasUnreadNotification || isManuallyUnread - } - private func normalizePinnedTabs(in paneId: PaneID) { guard !isNormalizingPinnedTabOrder else { return } isNormalizingPinnedTabOrder = true @@ -5440,6 +3860,7 @@ final class Workspace: Identifiable, ObservableObject { func markPanelUnread(_ panelId: UUID) { guard panels[panelId] != nil else { return } guard manualUnreadPanelIds.insert(panelId).inserted else { return } + manualUnreadMarkedAt[panelId] = Date() syncUnreadBadgeStateForPanel(panelId) } @@ -5450,10 +3871,34 @@ final class Workspace: Identifiable, ObservableObject { } func clearManualUnread(panelId: UUID) { - guard manualUnreadPanelIds.remove(panelId) != nil else { return } + let didRemoveUnread = manualUnreadPanelIds.remove(panelId) != nil + manualUnreadMarkedAt.removeValue(forKey: panelId) + guard didRemoveUnread else { return } syncUnreadBadgeStateForPanel(panelId) } + static func shouldClearManualUnread( + previousFocusedPanelId: UUID?, + nextFocusedPanelId: UUID, + isManuallyUnread: Bool, + markedAt: Date?, + now: Date = Date(), + sameTabGraceInterval: TimeInterval = manualUnreadFocusGraceInterval + ) -> Bool { + guard isManuallyUnread else { return false } + + if let previousFocusedPanelId, previousFocusedPanelId != nextFocusedPanelId { + return true + } + + guard let markedAt else { return true } + return now.timeIntervalSince(markedAt) >= sameTabGraceInterval + } + + static func shouldShowUnreadIndicator(hasUnreadNotification: Bool, isManuallyUnread: Bool) -> Bool { + hasUnreadNotification || isManuallyUnread + } + // MARK: - Title Management var hasCustomTitle: Bool { @@ -5495,7 +3940,7 @@ final class Workspace: Identifiable, ObservableObject { panelDirectories[panelId] = trimmed } // Update current directory if this is the focused panel - if panelId == focusedPanelId { + if panelId == focusedPanelId, currentDirectory != trimmed { currentDirectory = trimmed } } @@ -5587,6 +4032,7 @@ final class Workspace: Identifiable, ObservableObject { pinnedPanelIds = pinnedPanelIds.filter { validSurfaceIds.contains($0) } manualUnreadPanelIds = manualUnreadPanelIds.filter { validSurfaceIds.contains($0) } panelGitBranches = panelGitBranches.filter { validSurfaceIds.contains($0.key) } + manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } @@ -5650,10 +4096,6 @@ final class Workspace: Identifiable, ObservableObject { sidebarBranchDirectoryEntriesInDisplayOrder(orderedPanelIds: sidebarOrderedPanelIds()) } - var isRemoteWorkspace: Bool { - remoteConfiguration != nil - } - func sidebarPullRequestsInDisplayOrder(orderedPanelIds: [UUID]) -> [SidebarPullRequestState] { SidebarBranchOrdering.orderedUniquePullRequests( orderedPanelIds: orderedPanelIds, @@ -5682,7 +4124,9 @@ final class Workspace: Identifiable, ObservableObject { } } - // MARK: - Panel Operations + var isRemoteWorkspace: Bool { + remoteConfiguration != nil + } var remoteDisplayTarget: String? { remoteConfiguration?.displayTarget @@ -5958,6 +4402,67 @@ final class Workspace: Identifiable, ObservableObject { } } + // MARK: - Panel Operations + + private func seedTerminalInheritanceFontPoints( + panelId: UUID, + configTemplate: ghostty_surface_config_s? + ) { + guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return } + terminalInheritanceFontPointsByPanelId[panelId] = fontPoints + lastTerminalConfigInheritanceFontPoints = fontPoints + } + + private func resolvedTerminalInheritanceFontPoints( + for terminalPanel: TerminalPanel, + sourceSurface: ghostty_surface_t, + inheritedConfig: ghostty_surface_config_s + ) -> Float? { + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) + if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 { + if let runtimePoints, abs(runtimePoints - rooted) > 0.05 { + // Runtime zoom changed after lineage was seeded (manual zoom on descendant); + // treat runtime as the new root for future descendants. + return runtimePoints + } + return rooted + } + if inheritedConfig.font_size > 0 { + return inheritedConfig.font_size + } + return runtimePoints + } + + private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { + lastTerminalConfigInheritancePanelId = terminalPanel.id + if let sourceSurface = terminalPanel.surface.surface, + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { + let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] + if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints + } + lastTerminalConfigInheritanceFontPoints = + terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints + } + } + + func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { + guard let panelId = lastTerminalConfigInheritancePanelId else { return nil } + return terminalPanel(for: panelId) + } + + func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { + lastTerminalConfigInheritanceFontPoints + } + + /// Candidate terminal panels used as the source when creating inherited Ghostty config. + /// Preference order: + /// 1) explicitly preferred terminal panel (when the caller has one), + /// 2) selected terminal in the target pane, + /// 3) currently focused terminal in the workspace, + /// 4) last remembered terminal source, + /// 5) first terminal tab in the target pane, + /// 6) deterministic workspace fallback. private func terminalPanelConfigInheritanceCandidates( preferredPanelId: UUID? = nil, inPane preferredPaneId: PaneID? = nil @@ -5966,16 +4471,29 @@ final class Workspace: Identifiable, ObservableObject { var seen: Set = [] func appendCandidate(_ panel: TerminalPanel?) { - guard let panel else { return } - guard seen.insert(panel.id).inserted else { return } + guard let panel, seen.insert(panel.id).inserted else { return } candidates.append(panel) } - if let preferredPanelId, let preferredTerminal = terminalPanel(for: preferredPanelId) { - appendCandidate(preferredTerminal) + if let preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) { + appendCandidate(terminalPanel) } - appendCandidate(focusedTerminalPanel) + if let preferredPaneId, + let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id, + let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId), + let selectedTerminalPanel = terminalPanel(for: selectedPanelId) { + appendCandidate(selectedTerminalPanel) + } + + if let focusedTerminalPanel { + appendCandidate(focusedTerminalPanel) + } + + if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() { + appendCandidate(rememberedTerminalPanel) + } if let preferredPaneId { for tab in bonsplitController.tabs(inPane: preferredPaneId) { @@ -5994,6 +4512,7 @@ final class Workspace: Identifiable, ObservableObject { return candidates } + /// Picks the first terminal panel candidate used as the inheritance source. func terminalPanelForConfigInheritance( preferredPanelId: UUID? = nil, inPane preferredPaneId: PaneID? = nil @@ -6004,7 +4523,49 @@ final class Workspace: Identifiable, ObservableObject { ).first } - // MARK: - Panel Operations + private func inheritedTerminalConfig( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> ghostty_surface_config_s? { + // Walk candidates in priority order and use the first panel with a live surface. + // This avoids returning nil when the top candidate exists but is not attached yet. + for terminalPanel in terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ) { + guard let sourceSurface = terminalPanel.surface.surface else { continue } + var config = cmuxInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT + ) + if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( + for: terminalPanel, + sourceSurface: sourceSurface, + inheritedConfig: config + ), rootedFontPoints > 0 { + config.font_size = rootedFontPoints + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints + } + rememberTerminalConfigInheritanceSource(terminalPanel) + if config.font_size > 0 { + lastTerminalConfigInheritanceFontPoints = config.font_size + } + return config + } + + if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { + var config = ghostty_surface_config_new() + config.font_size = fallbackFontPoints +#if DEBUG + dlog( + "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" + ) +#endif + return config + } + + return nil + } /// Create a new split with a terminal panel @discardableResult @@ -6014,22 +4575,6 @@ final class Workspace: Identifiable, ObservableObject { insertFirst: Bool = false, focus: Bool = true ) -> TerminalPanel? { - // Get inherited config from the source terminal when possible. - // If the split is initiated from a non-terminal panel (for example browser), - // fall back to any terminal in the workspace. - let inheritedConfig: ghostty_surface_config_s? = { - if let sourceTerminal = terminalPanel(for: panelId), - let existing = sourceTerminal.surface.surface { - return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - if let fallbackSurface = panels.values - .compactMap({ ($0 as? TerminalPanel)?.surface.surface }) - .first { - return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - return nil - }() - // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? @@ -6042,26 +4587,18 @@ final class Workspace: Identifiable, ObservableObject { } guard let paneId = sourcePaneId else { return nil } - - // Inherit working directory: prefer the source panel's reported cwd, - // fall back to the workspace's current directory. - let splitWorkingDirectory: String? = panelDirectories[panelId] - ?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ? nil : currentDirectory) -#if DEBUG - dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")") -#endif + let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) // Create the new terminal panel. let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - workingDirectory: splitWorkingDirectory, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit // mutates layout state (avoids transient "Empty Panel" flashes during split). @@ -6075,43 +4612,44 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTab.id] = newPanel.id let previousFocusedPanelId = focusedPanelId - // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, - // so we can hand it to focusPanel as the "move focus FROM" view. - let previousHostedView = focusedTerminalPanel?.hostedView + // Capture the source terminal's hosted view before bonsplit mutates focusedPaneId, + // so we can hand it to focusPanel as the "move focus FROM" view. + let previousHostedView = focusedTerminalPanel?.hostedView - // Create the split with the new tab already present in the new pane. - isProgrammaticSplit = true - defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - panels.removeValue(forKey: newPanel.id) - panelTitles.removeValue(forKey: newPanel.id) - surfaceIdToPanelId.removeValue(forKey: newTab.id) - return nil - } + // Create the split with the new tab already present in the new pane. + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + panels.removeValue(forKey: newPanel.id) + panelTitles.removeValue(forKey: newPanel.id) + surfaceIdToPanelId.removeValue(forKey: newTab.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) + return nil + } #if DEBUG - dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") + dlog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") #endif - // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. - // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, - // stealing focus from the new panel and creating model/surface divergence. - if focus { - previousHostedView?.suppressReparentFocus() - focusPanel(newPanel.id, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: newPanel.id, - previousHostedView: previousHostedView - ) + // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. + // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, + // stealing focus from the new panel and creating model/surface divergence. + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(newPanel.id, previousHostedView: previousHostedView) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) + } - return newPanel - } + return newPanel + } /// Create a new surface (nested tab) in the specified pane with a terminal panel. /// - Parameter focus: nil = focus only if the target pane is already focused (default UI behavior), @@ -6126,16 +4664,7 @@ final class Workspace: Identifiable, ObservableObject { ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - // Get an existing terminal panel to inherit config from - let inheritedConfig: ghostty_surface_config_s? = { - for panel in panels.values { - if let terminalPanel = panel as? TerminalPanel, - let surface = terminalPanel.surface.surface { - return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - } - return nil - }() + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) // Create new terminal panel let newPanel = TerminalPanel( @@ -6148,6 +4677,7 @@ final class Workspace: Identifiable, ObservableObject { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit guard let newTabId = bonsplitController.createTab( @@ -6160,6 +4690,7 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -6203,7 +4734,8 @@ final class Workspace: Identifiable, ObservableObject { let browserPanel = BrowserPanel( workspaceId: id, initialURL: url, - proxyEndpoint: remoteProxyEndpoint + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -6220,32 +4752,32 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTab.id] = browserPanel.id let previousFocusedPanelId = focusedPanelId - // Create the split with the browser tab already present. - // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. - isProgrammaticSplit = true - defer { isProgrammaticSplit = false } - guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { - surfaceIdToPanelId.removeValue(forKey: newTab.id) - panels.removeValue(forKey: browserPanel.id) - panelTitles.removeValue(forKey: browserPanel.id) - return nil - } + // Create the split with the browser tab already present. + // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. + isProgrammaticSplit = true + defer { isProgrammaticSplit = false } + guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { + surfaceIdToPanelId.removeValue(forKey: newTab.id) + panels.removeValue(forKey: browserPanel.id) + panelTitles.removeValue(forKey: browserPanel.id) + return nil + } - // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. - let previousHostedView = focusedTerminalPanel?.hostedView - if focus { - previousHostedView?.suppressReparentFocus() - focusPanel(browserPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } - } else { - preserveFocusAfterNonFocusSplit( - preferredPanelId: previousFocusedPanelId, - splitPanelId: browserPanel.id, - previousHostedView: previousHostedView - ) - } + // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. + let previousHostedView = focusedTerminalPanel?.hostedView + if focus { + previousHostedView?.suppressReparentFocus() + focusPanel(browserPanel.id) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + previousHostedView?.clearSuppressReparentFocus() + } + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: browserPanel.id, + previousHostedView: previousHostedView + ) + } installBrowserPanelSubscription(browserPanel) browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) @@ -6271,7 +4803,8 @@ final class Workspace: Identifiable, ObservableObject { workspaceId: id, initialURL: url, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, - proxyEndpoint: remoteProxyEndpoint + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -6312,9 +4845,6 @@ final class Workspace: Identifiable, ObservableObject { return browserPanel } - // MARK: - Markdown Panel Creation - - /// Create a new markdown panel split from an existing panel. func newMarkdownSplit( from panelId: UUID, orientation: SplitOrientation, @@ -6322,7 +4852,6 @@ final class Workspace: Identifiable, ObservableObject { filePath: String, focus: Bool = true ) -> MarkdownPanel? { - // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? for paneId in bonsplitController.allPaneIds { @@ -6335,12 +4864,10 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } - // Create markdown panel let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel panelTitles[markdownPanel.id] = markdownPanel.displayTitle - // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. let newTab = Bonsplit.Tab( title: markdownPanel.displayTitle, icon: markdownPanel.displayIcon, @@ -6352,8 +4879,6 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTab.id] = markdownPanel.id let previousFocusedPanelId = focusedPanelId - // Create the split with the markdown tab already present in the new pane. - // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. isProgrammaticSplit = true defer { isProgrammaticSplit = false } guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { @@ -6363,7 +4888,6 @@ final class Workspace: Identifiable, ObservableObject { return nil } - // Suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView if focus { previousHostedView?.suppressReparentFocus() @@ -6380,11 +4904,9 @@ final class Workspace: Identifiable, ObservableObject { } installMarkdownPanelSubscription(markdownPanel) - return markdownPanel } - /// Create a new markdown surface (tab) in the specified pane. @discardableResult func newMarkdownSurface( inPane paneId: PaneID, @@ -6412,8 +4934,6 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = markdownPanel.id - - // Match terminal behavior: enforce deterministic selection + focus. if shouldFocusNewTab { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) @@ -6421,57 +4941,18 @@ final class Workspace: Identifiable, ObservableObject { } installMarkdownPanelSubscription(markdownPanel) - return markdownPanel } - /// Tear down all panels in this workspace, freeing their Ghostty surfaces. - /// Called before the workspace is removed from TabManager to ensure child - /// processes receive SIGHUP even if ARC deallocation is delayed. - func teardownAllPanels() { - let panelEntries = Array(panels) - for (panelId, panel) in panelEntries { - panelSubscriptions.removeValue(forKey: panelId) - PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) - panel.close() - } - - panels.removeAll(keepingCapacity: false) - surfaceIdToPanelId.removeAll(keepingCapacity: false) - panelSubscriptions.removeAll(keepingCapacity: false) - pruneSurfaceMetadata(validSurfaceIds: []) - restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false) - terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false) - lastTerminalConfigInheritancePanelId = nil - lastTerminalConfigInheritanceFontPoints = nil - } - /// Close a panel. /// Returns true when a bonsplit tab close request was issued. func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { -#if DEBUG - let mappedTabIdBeforeClose = surfaceIdFromPanelId(panelId) - dlog( - "surface.close.request panel=\(panelId.uuidString.prefix(5)) " + - "force=\(force ? 1 : 0) mappedTab=\(mappedTabIdBeforeClose.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + - "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + - "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil") " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" - ) -#endif if let tabId = surfaceIdFromPanelId(panelId) { if force { forceCloseTabIds.insert(tabId) } // Close the tab in bonsplit (this triggers delegate callback) - let closed = bonsplitController.closeTab(tabId) -#if DEBUG - dlog( - "surface.close.request.done panel=\(panelId.uuidString.prefix(5)) " + - "tab=\(String(describing: tabId).prefix(5)) closed=\(closed ? 1 : 0) force=\(force ? 1 : 0)" - ) -#endif - return closed + return bonsplitController.closeTab(tabId) } // Mapping can transiently drift during split-tree mutations. If the target panel is @@ -6503,38 +4984,12 @@ final class Workspace: Identifiable, ObservableObject { dlog( "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + "selectedTab=\(String(describing: selected.id).prefix(5)) " + - "closed=\(closed ? 1 : 0) " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" + "closed=\(closed ? 1 : 0)" ) #endif return closed } -#if DEBUG - private func debugPanelLifecycleState(panelId: UUID, panel: (any Panel)?) -> String { - guard let panel else { return "panelState=missing" } - if let terminal = panel as? TerminalPanel { - let hosted = terminal.hostedView - let frame = String(format: "%.1fx%.1f", hosted.frame.width, hosted.frame.height) - let bounds = String(format: "%.1fx%.1f", hosted.bounds.width, hosted.bounds.height) - let hasRuntimeSurface = terminal.surface.surface != nil ? 1 : 0 - return - "panelState=terminal panel=\(panelId.uuidString.prefix(5)) " + - "surface=\(terminal.id.uuidString.prefix(5)) runtimeSurface=\(hasRuntimeSurface) " + - "inWindow=\(hosted.window != nil ? 1 : 0) hasSuperview=\(hosted.superview != nil ? 1 : 0) " + - "hidden=\(hosted.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)" - } - if let browser = panel as? BrowserPanel { - let webView = browser.webView - let frame = String(format: "%.1fx%.1f", webView.frame.width, webView.frame.height) - return - "panelState=browser panel=\(panelId.uuidString.prefix(5)) " + - "webInWindow=\(webView.window != nil ? 1 : 0) webHasSuperview=\(webView.superview != nil ? 1 : 0) frame=\(frame)" - } - return "panelState=\(String(describing: type(of: panel))) panel=\(panelId.uuidString.prefix(5))" - } -#endif - func paneId(forPanelId panelId: UUID) -> PaneID? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } return bonsplitController.allPaneIds.first { paneId in @@ -6742,7 +5197,6 @@ final class Workspace: Identifiable, ObservableObject { in: bonsplitController.treeSnapshot() ) let resolvedURL = browserPanel.currentURL - ?? browserPanel.webView.url ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( @@ -6866,17 +5320,41 @@ final class Workspace: Identifiable, ObservableObject { func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } guard panels[panelId] != nil else { return nil } +#if DEBUG + let detachStart = ProcessInfo.processInfo.systemUptime + dlog( + "split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " + + "pendingDetached=\(pendingDetachedSurfaces.count)" + ) +#endif detachingTabIds.insert(tabId) forceCloseTabIds.insert(tabId) + activeDetachCloseTransactions += 1 + defer { activeDetachCloseTransactions = max(0, activeDetachCloseTransactions - 1) } guard bonsplitController.closeTab(tabId) else { detachingTabIds.remove(tabId) pendingDetachedSurfaces.removeValue(forKey: tabId) forceCloseTabIds.remove(tabId) +#if DEBUG + dlog( + "split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))" + ) +#endif return nil } - return pendingDetachedSurfaces.removeValue(forKey: tabId) + let detached = pendingDetachedSurfaces.removeValue(forKey: tabId) +#if DEBUG + dlog( + "split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + + "tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " + + "elapsedMs=\(debugElapsedMs(since: detachStart))" + ) +#endif + return detached } @discardableResult @@ -6886,8 +5364,31 @@ final class Workspace: Identifiable, ObservableObject { atIndex index: Int? = nil, focus: Bool = true ) -> UUID? { - guard bonsplitController.allPaneIds.contains(paneId) else { return nil } - guard panels[detached.panelId] == nil else { return nil } +#if DEBUG + let attachStart = ProcessInfo.processInfo.systemUptime + dlog( + "split.attach.begin ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)" + ) +#endif + guard bonsplitController.allPaneIds.contains(paneId) else { +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return nil + } + guard panels[detached.panelId] == nil else { +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif + return nil + } panels[detached.panelId] = detached.panel if let terminalPanel = detached.panel as? TerminalPanel { @@ -6915,8 +5416,10 @@ final class Workspace: Identifiable, ObservableObject { } if detached.manuallyUnread { manualUnreadPanelIds.insert(detached.panelId) + manualUnreadMarkedAt[detached.panelId] = .distantPast } else { manualUnreadPanelIds.remove(detached.panelId) + manualUnreadMarkedAt.removeValue(forKey: detached.panelId) } guard let newTabId = bonsplitController.createTab( @@ -6936,7 +5439,14 @@ final class Workspace: Identifiable, ObservableObject { panelCustomTitles.removeValue(forKey: detached.panelId) pinnedPanelIds.remove(detached.panelId) manualUnreadPanelIds.remove(detached.panelId) + manualUnreadMarkedAt.removeValue(forKey: detached.panelId) panelSubscriptions.removeValue(forKey: detached.panelId) +#if DEBUG + dlog( + "split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif return nil } @@ -6958,6 +5468,14 @@ final class Workspace: Identifiable, ObservableObject { } scheduleTerminalGeometryReconcile() +#if DEBUG + dlog( + "split.attach.end ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " + + "tab=\(newTabId.uuid.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5)) " + + "index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0) " + + "elapsedMs=\(debugElapsedMs(since: attachStart))" + ) +#endif return detached.panelId } // MARK: - Focus Management @@ -6978,6 +5496,9 @@ final class Workspace: Identifiable, ObservableObject { splitPanelId: splitPanelId ) + // Bonsplit splitPane focuses the newly created pane and may emit one delayed + // didSelect/didFocus callback. Re-assert focus over multiple turns so model + // focus and AppKit first responder stay aligned with non-focus-intent splits. reassertFocusAfterNonFocusSplit( generation: generation, preferredPanelId: preferredPanelId, @@ -7046,51 +5567,19 @@ final class Workspace: Identifiable, ObservableObject { terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId) } - private func beginNonFocusSplitFocusReassert( - preferredPanelId: UUID, - splitPanelId: UUID - ) -> UInt64 { - nonFocusSplitFocusReassertGeneration &+= 1 - let generation = nonFocusSplitFocusReassertGeneration - pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert( - generation: generation, - preferredPanelId: preferredPanelId, - splitPanelId: splitPanelId - ) - return generation - } - - private func matchesPendingNonFocusSplitFocusReassert( - generation: UInt64, - preferredPanelId: UUID, - splitPanelId: UUID - ) -> Bool { - guard let pending = pendingNonFocusSplitFocusReassert else { return false } - return pending.generation == generation && - pending.preferredPanelId == preferredPanelId && - pending.splitPanelId == splitPanelId - } - - private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) { - guard let pending = pendingNonFocusSplitFocusReassert else { return } - if let generation, pending.generation != generation { return } - pendingNonFocusSplitFocusReassert = nil - } - - private func markExplicitFocusIntent(on panelId: UUID) { - guard let pending = pendingNonFocusSplitFocusReassert, - pending.splitPanelId == panelId else { - return - } - pendingNonFocusSplitFocusReassert = nil - } - - func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) { + func focusPanel( + _ panelId: UUID, + previousHostedView: GhosttySurfaceScrollView? = nil, + trigger: FocusPanelTrigger = .standard + ) { markExplicitFocusIntent(on: panelId) #if DEBUG let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" - dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)") - FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)") + let triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard" + dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)") + FocusLogStore.shared.append( + "Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)" + ) #endif guard let tabId = surfaceIdFromPanelId(panelId) else { return } let currentlyFocusedPanelId = focusedPanelId @@ -7113,6 +5602,15 @@ final class Workspace: Identifiable, ObservableObject { return bonsplitController.focusedPaneId == targetPaneId && bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId }() + let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged +#if DEBUG + if shouldSuppressReentrantRefocus { + dlog( + "focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " + + "reason=firstResponderAlreadyConverged" + ) + } +#endif if let targetPaneId, !selectionAlreadyConverged { bonsplitController.focusPane(targetPaneId) @@ -7124,11 +5622,11 @@ final class Workspace: Identifiable, ObservableObject { // Also focus the underlying panel if let panel = panels[panelId] { - if currentlyFocusedPanelId != panelId || !selectionAlreadyConverged { + if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus { panel.focus() } - if let terminalPanel = panel as? TerminalPanel { + if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel { // Avoid re-entrant focus loops when focus was initiated by AppKit first-responder // (becomeFirstResponder -> onFocus -> focusPanel). if !terminalPanel.hostedView.isSurfaceViewFirstResponder() { @@ -7136,19 +5634,12 @@ final class Workspace: Identifiable, ObservableObject { } } } - if let targetPaneId { + if let targetPaneId, !shouldSuppressReentrantRefocus { applyTabSelection(tabId: tabId, inPane: targetPaneId) } if let browserPanel = panels[panelId] as? BrowserPanel { - // Keep browser find focus behavior aligned with terminal find behavior. - // When switching back to a pane with an already-open find bar, reassert - // focus to that field instead of leaving first responder stale. - if browserPanel.searchState != nil { - browserPanel.startFind() - } else { - maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) - } + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) } } @@ -7289,14 +5780,7 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Flash/Notification Support func triggerFocusFlash(panelId: UUID) { - if let terminalPanel = terminalPanel(for: panelId) { - terminalPanel.triggerFlash() - return - } - if let browserPanel = browserPanel(for: panelId) { - browserPanel.triggerFlash() - return - } + panels[panelId]?.triggerFlash() } func triggerNotificationFocusFlash( @@ -7312,7 +5796,7 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerNotificationDismissFlash() + terminalPanel.triggerFlash() } func triggerDebugFlash(panelId: UUID) { @@ -7332,16 +5816,10 @@ final class Workspace: Identifiable, ObservableObject { } } - /// Hide all browser portal views for this workspace. - /// Called before the workspace is unmounted so a portal-hosted WKWebView - /// cannot remain visible after this workspace stops being selected. func hideAllBrowserPortalViews() { for panel in panels.values { guard let browser = panel as? BrowserPanel else { continue } - BrowserWindowPortalRegistry.hide( - webView: browser.webView, - source: "workspaceRetire" - ) + browser.hideBrowserPortalView(source: "workspaceRetire") } } @@ -7350,14 +5828,19 @@ final class Workspace: Identifiable, ObservableObject { /// Create a new terminal panel (used when replacing the last panel) @discardableResult func createReplacementTerminalPanel() -> TerminalPanel { + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: focusedPanelId, + inPane: bonsplitController.focusedPaneId + ) let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: nil, + configTemplate: inheritedConfig, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit if let newTabId = bonsplitController.createTab( @@ -7442,7 +5925,7 @@ final class Workspace: Identifiable, ObservableObject { /// Coalesce to the next main-queue turn so bonsplit selection/pane mutations settle first. private func scheduleFocusReconcile() { #if DEBUG - if !detachingTabIds.isEmpty { + if isDetachingCloseTransaction { debugFocusReconcileScheduledDuringDetachCount += 1 } #endif @@ -7479,11 +5962,11 @@ final class Workspace: Identifiable, ObservableObject { needsFollowUpPass = true } - let geometryChanged = hostedView.reconcileGeometryNow() + hostedView.reconcileGeometryNow() // Re-check surface after reconcileGeometryNow() which can trigger AppKit // layout and view lifecycle changes that free surfaces (#432). - if geometryChanged, terminalPanel.surface.surface != nil { - terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile") + if terminalPanel.surface.surface != nil { + terminalPanel.surface.forceRefresh() } if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -7516,6 +5999,7 @@ final class Workspace: Identifiable, ObservableObject { geometryReconcileScheduled = false geometryReconcileNeedsRerun = false } + private func scheduleTerminalGeometryReconcile() { guard !geometryReconcileScheduled else { geometryReconcileNeedsRerun = true @@ -7538,9 +6022,9 @@ final class Workspace: Identifiable, ObservableObject { 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 } - let geometryChanged = panel.hostedView.reconcileGeometryNow() - if geometryChanged, panel.surface.surface != nil { - panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh") + panel.hostedView.reconcileGeometryNow() + if panel.surface.surface != nil { + panel.surface.forceRefresh() } if panel.surface.surface == nil { panel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -7607,15 +6091,15 @@ final class Workspace: Identifiable, ObservableObject { let panel = panels[panelId] else { return } let alert = NSAlert() - alert.messageText = String(localized: "dialog.renameTab.title", defaultValue: "Rename Tab") - alert.informativeText = String(localized: "dialog.renameTab.message", defaultValue: "Enter a custom name for this tab.") + alert.messageText = "Rename Tab" + alert.informativeText = "Enter a custom name for this tab." let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle let input = NSTextField(string: currentTitle) - input.placeholderString = String(localized: "dialog.renameTab.placeholder", defaultValue: "Tab name") + input.placeholderString = "Tab name" input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input - alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") let alertWindow = alert.window alertWindow.initialFirstResponder = input DispatchQueue.main.async { @@ -7644,24 +6128,24 @@ final class Workspace: Identifiable, ObservableObject { ) var options: [(title: String, destination: PanelMoveDestination)] = [ - (String(localized: "dialog.moveTab.newWorkspaceCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), - (String(localized: "dialog.moveTab.selectedWorkspaceNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), + ("New Workspace in Current Window", .newWorkspaceInCurrentWindow), + ("Selected Workspace in New Window", .selectedWorkspaceInNewWindow), ] options.append(contentsOf: workspaceTargets.map { target in (target.label, .existingWorkspace(target.workspaceId)) }) let alert = NSAlert() - alert.messageText = String(localized: "dialog.moveTab.title", defaultValue: "Move Tab") - alert.informativeText = String(localized: "dialog.moveTab.message", defaultValue: "Choose a destination for this tab.") + alert.messageText = "Move Tab" + alert.informativeText = "Choose a destination for this tab." let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) for option in options { popup.addItem(withTitle: option.title) } popup.selectItem(at: 0) alert.accessoryView = popup - alert.addButton(withTitle: String(localized: "dialog.moveTab.move", defaultValue: "Move")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Move") + alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return } let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) @@ -7707,9 +6191,9 @@ final class Workspace: Identifiable, ObservableObject { if !moved { let failure = NSAlert() failure.alertStyle = .warning - failure.messageText = String(localized: "dialog.moveFailed.title", defaultValue: "Move Failed") - failure.informativeText = String(localized: "dialog.moveFailed.message", defaultValue: "cmux could not move this tab to the selected destination.") - failure.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + failure.messageText = "Move Failed" + failure.informativeText = "cmux could not move this tab to the selected destination." + failure.addButton(withTitle: "OK") _ = failure.runModal() } } @@ -7767,6 +6251,7 @@ final class Workspace: Identifiable, ObservableObject { #endif return moved } + } // MARK: - BonsplitDelegate @@ -7775,11 +6260,11 @@ extension Workspace: BonsplitDelegate { @MainActor private func confirmClosePanel(for tabId: TabID) async -> Bool { let alert = NSAlert() - alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?") - alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") + alert.messageText = "Close tab?" + alert.informativeText = "This will close the current tab." alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Close") + alert.addButton(withTitle: "Cancel") // Prefer a sheet if we can find a window, otherwise fall back to modal. if let window = NSApp.keyWindow ?? NSApp.mainWindow { @@ -7814,6 +6299,7 @@ extension Workspace: BonsplitDelegate { } private func applyTabSelectionNow(tabId: TabID, inPane pane: PaneID) { + let previousFocusedPanelId = focusedPanelId if bonsplitController.allPaneIds.contains(pane) { if bonsplitController.focusedPaneId != pane { bonsplitController.focusPane(pane) @@ -7844,6 +6330,11 @@ extension Workspace: BonsplitDelegate { let panel = panels[panelId] else { return } + + if shouldTreatCurrentEventAsExplicitFocusIntent() { + markExplicitFocusIntent(on: panelId) + } + syncPinnedStateForTab(selectedTabId, panelId: panelId) syncUnreadBadgeStateForPanel(panelId) @@ -7853,7 +6344,34 @@ extension Workspace: BonsplitDelegate { } panel.focus() - clearManualUnread(panelId: panelId) + let focusIntentAllowsBrowserOmnibarAutofocus = + shouldTreatCurrentEventAsExplicitFocusIntent() || + TerminalController.socketCommandAllowsInAppFocusMutations() + if let browserPanel = panel as? BrowserPanel, + previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard) + } + if let terminalPanel = panel as? TerminalPanel { + rememberTerminalConfigInheritanceSource(terminalPanel) + } + let isManuallyUnread = manualUnreadPanelIds.contains(panelId) + let markedAt = manualUnreadMarkedAt[panelId] + if Self.shouldClearManualUnread( + previousFocusedPanelId: previousFocusedPanelId, + nextFocusedPanelId: panelId, + isManuallyUnread: isManuallyUnread, + markedAt: markedAt + ) { + triggerFocusFlash(panelId: panelId) + let clearDelay = Self.manualUnreadClearDelayAfterFocusFlash + if clearDelay <= 0 { + clearManualUnread(panelId: panelId) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + clearDelay) { [weak self] in + self?.clearManualUnread(panelId: panelId) + } + } + } // Converge AppKit first responder with bonsplit's selected tab in the focused pane. // Without this, keyboard input can remain on a different terminal than the blue tab indicator. @@ -7865,7 +6383,8 @@ extension Workspace: BonsplitDelegate { if let dir = panelDirectories[panelId] { currentDirectory = dir } - refreshFocusedGitBranchState() + gitBranch = panelGitBranches[panelId] + pullRequest = panelPullRequests[panelId] // Post notification NotificationCenter.default.post( @@ -7878,16 +6397,57 @@ extension Workspace: BonsplitDelegate { ) } - private func refreshFocusedGitBranchState() { - if let focusedPanelId { - gitBranch = panelGitBranches[focusedPanelId] - pullRequest = panelPullRequests[focusedPanelId] - } else { - gitBranch = nil - pullRequest = nil + private func beginNonFocusSplitFocusReassert( + preferredPanelId: UUID, + splitPanelId: UUID + ) -> UInt64 { + nonFocusSplitFocusReassertGeneration &+= 1 + let generation = nonFocusSplitFocusReassertGeneration + pendingNonFocusSplitFocusReassert = PendingNonFocusSplitFocusReassert( + generation: generation, + preferredPanelId: preferredPanelId, + splitPanelId: splitPanelId + ) + return generation + } + + private func matchesPendingNonFocusSplitFocusReassert( + generation: UInt64, + preferredPanelId: UUID, + splitPanelId: UUID + ) -> Bool { + guard let pending = pendingNonFocusSplitFocusReassert else { return false } + return pending.generation == generation && + pending.preferredPanelId == preferredPanelId && + pending.splitPanelId == splitPanelId + } + + private func clearNonFocusSplitFocusReassert(generation: UInt64? = nil) { + guard let pending = pendingNonFocusSplitFocusReassert else { return } + if let generation, pending.generation != generation { return } + pendingNonFocusSplitFocusReassert = nil + } + + private func shouldTreatCurrentEventAsExplicitFocusIntent() -> Bool { + guard let eventType = NSApp.currentEvent?.type else { return false } + switch eventType { + case .leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp, + .otherMouseDown, .otherMouseUp, .keyDown, .keyUp, .scrollWheel, + .gesture, .magnify, .rotate, .swipe: + return true + default: + return false } } + private func markExplicitFocusIntent(on panelId: UUID) { + guard let pending = pendingNonFocusSplitFocusReassert, + pending.splitPanelId == panelId else { + return + } + pendingNonFocusSplitFocusReassert = nil + } + func splitTabBar(_ controller: BonsplitController, shouldCloseTab tab: Bonsplit.Tab, inPane pane: PaneID) -> Bool { func recordPostCloseSelection() { let tabs = controller.tabs(inPane: pane) @@ -7969,46 +6529,41 @@ extension Workspace: BonsplitDelegate { forceCloseTabIds.remove(tabId) let selectTabId = postCloseSelectTabId.removeValue(forKey: tabId) let closedBrowserRestoreSnapshot = pendingClosedBrowserRestoreSnapshots.removeValue(forKey: tabId) + let isDetaching = detachingTabIds.remove(tabId) != nil || isDetachingCloseTransaction // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { #if DEBUG - dlog( - "surface.didCloseTab.skip tab=\(String(describing: tabId).prefix(5)) " + - "pane=\(pane.id.uuidString.prefix(5)) reason=missingPanelMapping " + - "panels=\(panels.count) panes=\(controller.allPaneIds.count)" - ) + NSLog("[Workspace] didCloseTab: no panelId for tabId") #endif - refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetaching { + scheduleFocusReconcile() + } return } + #if DEBUG + NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") + #endif + let panel = panels[panelId] -#if DEBUG - dlog( - "surface.didCloseTab.begin tab=\(String(describing: tabId).prefix(5)) " + - "pane=\(pane.id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + - "isDetaching=\(isDetaching ? 1 : 0) selectAfter=\(selectTabId.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panel))" - ) -#endif if isDetaching, let panel { let browserPanel = panel as? BrowserPanel - let cachedTitle = panelTitles[panelId] ?? panel.displayTitle + let cachedTitle = panelTitles[panelId] + let transferFallbackTitle = cachedTitle ?? panel.displayTitle pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer( panelId: panelId, panel: panel, - title: resolvedPanelTitle(panelId: panelId, fallback: cachedTitle), + title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle), icon: panel.displayIcon, iconImageData: browserPanel?.faviconPNGData, kind: surfaceKind(for: panel), isLoading: browserPanel?.isLoading ?? false, isPinned: pinnedPanelIds.contains(panelId), directory: panelDirectories[panelId], - cachedTitle: panelTitles[panelId], + cachedTitle: cachedTitle, customTitle: panelCustomTitles[panelId], manuallyUnread: manualUnreadPanelIds.contains(panelId) ) @@ -8022,31 +6577,31 @@ extension Workspace: BonsplitDelegate { panels.removeValue(forKey: panelId) surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.removeValue(forKey: panelId) + panelGitBranches.removeValue(forKey: panelId) panelPullRequests.removeValue(forKey: panelId) panelTitles.removeValue(forKey: panelId) panelCustomTitles.removeValue(forKey: panelId) pinnedPanelIds.remove(panelId) manualUnreadPanelIds.remove(panelId) - panelGitBranches.removeValue(forKey: panelId) + manualUnreadMarkedAt.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) + if lastTerminalConfigInheritancePanelId == panelId { + lastTerminalConfigInheritancePanelId = nil + } - // Keep the workspace invariant: always retain at least one real panel. - // This prevents runtime close callbacks from ever collapsing into a tabless workspace. + // Keep the workspace invariant for normal close paths. + // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can + // prune the source workspace/window after the tab is attached elsewhere. if panels.isEmpty { if isDetaching { - gitBranch = nil -#if DEBUG - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) mode=detachingEmptyWorkspace" - ) -#endif scheduleTerminalGeometryReconcile() return } + let replacement = createReplacementTerminalPanel() if let replacementTabId = surfaceIdFromPanelId(replacement.id), let replacementPane = bonsplitController.allPaneIds.first { @@ -8054,16 +6609,8 @@ extension Workspace: BonsplitDelegate { bonsplitController.selectTab(replacementTabId) applyTabSelection(tabId: replacementTabId, inPane: replacementPane) } - refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() scheduleFocusReconcile() -#if DEBUG - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) mode=replacementCreated " + - "replacement=\(replacement.id.uuidString.prefix(5)) panels=\(panels.count)" - ) -#endif return } @@ -8075,24 +6622,20 @@ extension Workspace: BonsplitDelegate { // frame where the pane has no selected content. bonsplitController.selectTab(selectTabId) applyTabSelection(tabId: selectTabId, inPane: pane) + } else if let focusedPane = bonsplitController.focusedPaneId, + let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { + // When closing the last tab in a pane, Bonsplit may focus a different pane and skip + // emitting didSelectTab. Re-apply the focused selection so sidebar state stays in sync. + applyTabSelection(tabId: focusedTabId, inPane: focusedPane) } if bonsplitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } - refreshFocusedGitBranchState() -#if DEBUG - let focusedPaneAfter = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" - let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) panes=\(controller.allPaneIds.count) " + - "focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter)" - ) -#endif - refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetaching { + scheduleFocusReconcile() + } } func splitTabBar(_ controller: BonsplitController, didSelectTab tab: Bonsplit.Tab, inPane pane: PaneID) { @@ -8101,18 +6644,56 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) { #if DEBUG - let movedPanel = panelIdFromSurfaceId(tab.id)?.uuidString.prefix(5) ?? "unknown" + let now = ProcessInfo.processInfo.systemUptime + let sincePrev: String + if debugLastDidMoveTabTimestamp > 0 { + sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000) + } else { + sincePrev = "first" + } + debugLastDidMoveTabTimestamp = now + debugDidMoveTabEventCount += 1 + let movedPanelId = panelIdFromSurfaceId(tab.id) + let movedPanel = movedPanelId?.uuidString.prefix(5) ?? "unknown" + let selectedBefore = controller.selectedTab(inPane: destination) + .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" + let focusedPaneBefore = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil" dlog( - "split.moveTab panel=\(movedPanel) " + + "split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " + "from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " + "sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)" ) + dlog( + "split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + + "destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)" + ) #endif applyTabSelection(tabId: tab.id, inPane: destination) +#if DEBUG + let movedPanelIdAfter = panelIdFromSurfaceId(tab.id) +#endif + if let movedPanelId = panelIdFromSurfaceId(tab.id) { + scheduleMovedTerminalRefresh(panelId: movedPanelId) + } +#if DEBUG + let selectedAfter = controller.selectedTab(inPane: destination) + .map { String(String(describing: $0.id).prefix(5)) } ?? "nil" + let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" + let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" + let movedPanelFocused = (movedPanelIdAfter != nil && movedPanelIdAfter == focusedPanelId) ? 1 : 0 + dlog( + "split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " + + "destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " + + "movedFocused=\(movedPanelFocused)" + ) +#endif normalizePinnedTabs(in: source) normalizePinnedTabs(in: destination) scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } } func splitTabBar(_ controller: BonsplitController, didFocusPane pane: PaneID) { @@ -8135,21 +6716,9 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] let shouldScheduleFocusReconcile = !isDetachingCloseTransaction -#if DEBUG - dlog( - "surface.didClosePane.begin pane=\(paneId.id.uuidString.prefix(5)) " + - "closedPanels=\(closedPanelIds.count) detaching=\(isDetachingCloseTransaction ? 1 : 0)" - ) -#endif if !closedPanelIds.isEmpty { for panelId in closedPanelIds { -#if DEBUG - dlog( - "surface.didClosePane.panel pane=\(paneId.id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) \(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" - ) -#endif panels[panelId]?.close() panels.removeValue(forKey: panelId) panelDirectories.removeValue(forKey: panelId) @@ -8169,19 +6738,19 @@ extension Workspace: BonsplitDelegate { let closedSet = Set(closedPanelIds) surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } recomputeListeningPorts() + + if let focusedPane = bonsplitController.focusedPaneId, + let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { + applyTabSelection(tabId: focusedTabId, inPane: focusedPane) + } else if shouldScheduleFocusReconcile { + scheduleFocusReconcile() + } } - refreshFocusedGitBranchState() scheduleTerminalGeometryReconcile() if shouldScheduleFocusReconcile { scheduleFocusReconcile() } -#if DEBUG - dlog( - "surface.didClosePane.end pane=\(paneId.id.uuidString.prefix(5)) " + - "remainingPanels=\(panels.count) remainingPanes=\(bonsplitController.allPaneIds.count)" - ) -#endif } func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { @@ -8192,9 +6761,11 @@ extension Workspace: BonsplitDelegate { if let panelId = panelIdFromSurfaceId(tab.id), let terminalPanel = terminalPanel(for: panelId), terminalPanel.needsConfirmClose() { + pendingPaneClosePanelIds.removeValue(forKey: pane.id) return false } } + pendingPaneClosePanelIds[pane.id] = tabs.compactMap { panelIdFromSurfaceId($0.id) } return true } @@ -8264,15 +6835,7 @@ extension Workspace: BonsplitDelegate { // Keep the existing placeholder tab identity and replace only the panel mapping. // This avoids an extra create+close tab churn that can transiently render an // empty pane during drag-to-split of a single-tab pane. - let inheritedConfig: ghostty_surface_config_s? = { - for panel in panels.values { - if let terminalPanel = panel as? TerminalPanel, - let surface = terminalPanel.surface.surface { - return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - } - return nil - }() + let inheritedConfig = inheritedTerminalConfig(inPane: originalPane) let replacementPanel = TerminalPanel( workspaceId: id, @@ -8282,6 +6845,7 @@ extension Workspace: BonsplitDelegate { ) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) surfaceIdToPanelId[replacementTab.id] = replacementPanel.id bonsplitController.updateTab( @@ -8334,11 +6898,10 @@ extension Workspace: BonsplitDelegate { ) #endif - let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface { - ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } else { - nil - } + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: sourcePanelId, + inPane: originalPane + ) let newPanel = TerminalPanel( workspaceId: id, @@ -8348,6 +6911,7 @@ extension Workspace: BonsplitDelegate { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, @@ -8359,6 +6923,7 @@ extension Workspace: BonsplitDelegate { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return } @@ -8408,8 +6973,7 @@ extension Workspace: BonsplitDelegate { case .closeOthers: closeTabs(tabIdsToCloseOthers(of: tab.id, inPane: pane)) case .move: - // TODO: Wire this to a move target picker. - return + promptMovePanel(tabId: tab.id) case .newTerminalToRight: createTerminalToRight(of: tab.id, inPane: pane) case .newBrowserToRight: @@ -8427,7 +6991,6 @@ extension Workspace: BonsplitDelegate { case .markAsRead: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } clearManualUnread(panelId: panelId) - AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) case .markAsUnread: guard let panelId = panelIdFromSurfaceId(tab.id) else { return } markPanelUnread(panelId) @@ -8442,7 +7005,9 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { _ = snapshot scheduleTerminalGeometryReconcile() - scheduleFocusReconcile() + if !isDetachingCloseTransaction { + scheduleFocusReconcile() + } } // No post-close polling refresh loop: we rely on view invariants and Ghostty's wakeups.